feat(graph): Implement Graph View with Dagre Layout and Epic Scope (bb-18e)
This commit is contained in:
parent
7ab23448f0
commit
8490cb1d8c
33 changed files with 4936 additions and 38 deletions
219
src/components/graph/dependency-flow-strip.tsx
Normal file
219
src/components/graph/dependency-flow-strip.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
'use client';
|
||||
|
||||
import type { GraphNode } from '../../lib/graph';
|
||||
import type { PathWorkspace } from '../../lib/graph-view';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
/** Props for an individual flow card in the dependency strip. */
|
||||
interface FlowCardProps {
|
||||
/** The graph node data for this card. */
|
||||
node: GraphNode;
|
||||
/** Whether this card is the currently selected/focused task. */
|
||||
selected: boolean;
|
||||
/** Number of issues blocking this node. */
|
||||
blockedBy: number;
|
||||
/** Number of issues this node blocks. */
|
||||
blocks: number;
|
||||
/** Callback fired when the user clicks this card. */
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Props for the DependencyFlowStrip component. */
|
||||
interface DependencyFlowStripProps {
|
||||
/** The computed path workspace containing blockers, focus, and dependents. */
|
||||
workspace: PathWorkspace;
|
||||
/** ID of the currently selected task, or null. */
|
||||
selectedId: string | null;
|
||||
/** Map of issue ID to blocker/blocks counts. */
|
||||
signalById: Map<string, { blockedBy: number; blocks: number }>;
|
||||
/** Callback fired when the user selects a card. */
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Tailwind background color class for a status dot indicator.
|
||||
*/
|
||||
function statusDot(status: BeadIssue['status']): string {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'bg-sky-400';
|
||||
case 'in_progress':
|
||||
return 'bg-amber-400';
|
||||
case 'blocked':
|
||||
return 'bg-rose-500';
|
||||
case 'deferred':
|
||||
return 'bg-slate-400';
|
||||
case 'closed':
|
||||
return 'bg-emerald-400';
|
||||
case 'pinned':
|
||||
return 'bg-violet-400';
|
||||
case 'hooked':
|
||||
return 'bg-orange-400';
|
||||
default:
|
||||
return 'bg-zinc-500';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact card representing a single node in the dependency flow.
|
||||
* Shows ID, title, status, and blocker/blocks counts.
|
||||
*/
|
||||
function FlowCard({ node, selected, blockedBy, blocks, onSelect }: FlowCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.id)}
|
||||
className={`workflow-card w-full rounded-xl px-3 py-2.5 text-left transition duration-200 ${selected
|
||||
? 'workflow-card-selected'
|
||||
: 'hover:border-sky-300/40 hover:bg-[linear-gradient(165deg,rgba(76,94,134,0.2),rgba(18,20,30,0.84))]'
|
||||
}`}
|
||||
>
|
||||
{/* Header: node ID + status dot */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-[9px] tracking-[0.04em] text-text-muted/80">{node.id}</span>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${statusDot(node.status)}`} />
|
||||
</div>
|
||||
{/* Node title - truncates at 2 lines */}
|
||||
<p className="mt-1 text-[12px] font-semibold leading-tight text-text-strong line-clamp-2">{node.title}</p>
|
||||
{/* Dependency signal counts */}
|
||||
<p className="mt-1 text-[10px] text-text-body">
|
||||
{blockedBy} blockers • {blocks} dependents
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a section header with a count badge.
|
||||
*/
|
||||
function SectionHeader({ label, count, color }: { label: string; count: number; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-[0.15em] ${color}`}>{label}</span>
|
||||
<span className="rounded-md bg-white/5 px-1.5 py-0.5 text-[9px] font-bold text-text-muted/60">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dependency flow as three responsive sections stacked vertically:
|
||||
* Blocked By, Selected/Focus, and Blocks (Dependents).
|
||||
* Each section uses a responsive wrapping grid so cards never overflow.
|
||||
* On larger screens the three sections sit side-by-side; on smaller screens they stack.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
|
||||
// ... (FlowCardProps, DependencyFlowStripProps, statusDot, FlowCard, SectionHeader definitions remain unchanged)
|
||||
|
||||
/**
|
||||
* Renders the dependency flow as three responsive sections stacked vertically:
|
||||
* Blocked By, Selected/Focus, and Blocks (Dependents).
|
||||
* Each section uses a responsive wrapping grid so cards never overflow.
|
||||
* On larger screens the three sections sit side-by-side; on smaller screens they stack.
|
||||
*/
|
||||
export function DependencyFlowStrip({ workspace, selectedId, signalById, onSelect }: DependencyFlowStripProps) {
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
|
||||
// Flatten the multi-hop blocker/dependent arrays for display
|
||||
const blockerNodes = workspace.blockers.flat();
|
||||
const dependentNodes = workspace.dependents.flat();
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/5 bg-white/[0.01] px-5 py-4 ring-1 ring-white/5 transition-all duration-300">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">
|
||||
Dependency Flow
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setMinimized(!minimized)}
|
||||
className="rounded p-1 hover:bg-white/5 text-text-muted transition-colors"
|
||||
title={minimized ? "Expand" : "Minimize"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`transition-transform duration-200 ${minimized ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<polyline points="18 15 12 9 6 15"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Responsive three-column layout: stacks on mobile, side-by-side on desktop */}
|
||||
{!minimized && (
|
||||
<div className="grid gap-4 md:grid-cols-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{/* Blocked By section */}
|
||||
<div>
|
||||
<SectionHeader label="Blocked By" count={blockerNodes.length} color="text-rose-400/70" />
|
||||
{blockerNodes.length > 0 ? (
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]">
|
||||
{blockerNodes.map((node) => (
|
||||
<FlowCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
selected={selectedId === node.id}
|
||||
blockedBy={signalById.get(node.id)?.blockedBy ?? 0}
|
||||
blocks={signalById.get(node.id)?.blocks ?? 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
|
||||
<p className="text-[10px] text-text-muted/40">No blockers</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected / Focused task section */}
|
||||
<div>
|
||||
<SectionHeader label="Selected" count={workspace.focus ? 1 : 0} color="text-sky-400/70" />
|
||||
{workspace.focus ? (
|
||||
<FlowCard
|
||||
node={workspace.focus}
|
||||
selected
|
||||
blockedBy={signalById.get(workspace.focus.id)?.blockedBy ?? 0}
|
||||
blocks={signalById.get(workspace.focus.id)?.blocks ?? 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
|
||||
<p className="text-[10px] text-text-muted/40">Select a task</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blocks (Dependents) section */}
|
||||
<div>
|
||||
<SectionHeader label="Blocks" count={dependentNodes.length} color="text-amber-400/70" />
|
||||
{dependentNodes.length > 0 ? (
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]">
|
||||
{dependentNodes.map((node) => (
|
||||
<FlowCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
selected={selectedId === node.id}
|
||||
blockedBy={signalById.get(node.id)?.blockedBy ?? 0}
|
||||
blocks={signalById.get(node.id)?.blocks ?? 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
|
||||
<p className="text-[10px] text-text-muted/40">No dependents</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
921
src/components/graph/dependency-graph-page.tsx
Normal file
921
src/components/graph/dependency-graph-page.tsx
Normal file
|
|
@ -0,0 +1,921 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
MarkerType,
|
||||
Position,
|
||||
ReactFlowProvider,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeMouseHandler,
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import dagre from 'dagre';
|
||||
|
||||
import { EpicChipStrip } from './epic-chip-strip';
|
||||
import { WorkflowTabs, type WorkflowTab } from './workflow-tabs';
|
||||
import { TaskCardGrid, type BlockerDetail } from './task-card-grid';
|
||||
import { TaskDetailsDrawer } from './task-details-drawer';
|
||||
import { DependencyFlowStrip } from './dependency-flow-strip';
|
||||
import { GraphNodeCard, type GraphNodeData } from './graph-node-card';
|
||||
import { GraphSection } from './graph-section';
|
||||
import { ProjectScopeControls } from '../shared/project-scope-controls';
|
||||
|
||||
import { buildGraphModel, type GraphNode } from '../../lib/graph';
|
||||
import {
|
||||
buildPathWorkspace,
|
||||
type GraphHopDepth,
|
||||
analyzeBlockedChain,
|
||||
detectDependencyCycles,
|
||||
} from '../../lib/graph-view';
|
||||
import { buildBlockedByTree, type BlockedTreeNode } from '../../lib/kanban';
|
||||
import { type BeadIssue } from '../../lib/types';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
|
||||
/** Props for the DependencyGraphPage component. */
|
||||
interface DependencyGraphPageProps {
|
||||
/** All issues in the project. */
|
||||
issues: BeadIssue[];
|
||||
/** The project root key for graph model construction. */
|
||||
projectRoot: string;
|
||||
/** URL scope key (local or registry key). */
|
||||
projectScopeKey: string;
|
||||
/** Available scope options for context rendering. */
|
||||
projectScopeOptions: ProjectScopeOption[];
|
||||
/** Scope mode selection from URL (single/aggregate). */
|
||||
projectScopeMode: 'single' | 'aggregate';
|
||||
}
|
||||
|
||||
/** Available hop depth values for the depth selector. */
|
||||
const DEPTH_OPTIONS: GraphHopDepth[] = [1, 2, 'full'];
|
||||
|
||||
|
||||
/**
|
||||
* Positions nodes using the Dagre graph layout engine.
|
||||
* This respects dependency direction (Left-to-Right) and creates a true flowchart.
|
||||
*/
|
||||
function layoutDagre(nodes: Node<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,
|
||||
projectRoot,
|
||||
projectScopeKey,
|
||||
projectScopeOptions,
|
||||
projectScopeMode,
|
||||
}: DependencyGraphPageProps) {
|
||||
const router = useRouter();
|
||||
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 kanbanHref = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (projectScopeMode !== 'single') {
|
||||
params.set('mode', projectScopeMode);
|
||||
}
|
||||
if (projectScopeKey !== 'local') {
|
||||
params.set('project', projectScopeKey);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `/?${query}` : '/';
|
||||
}, [projectScopeKey, projectScopeMode]);
|
||||
const activeScope = useMemo(
|
||||
() => projectScopeOptions.find((option) => option.key === projectScopeKey) ?? projectScopeOptions[0] ?? null,
|
||||
[projectScopeKey, projectScopeOptions],
|
||||
);
|
||||
|
||||
// --- Derived data: epics ---
|
||||
const epics = useMemo(
|
||||
() =>
|
||||
issues
|
||||
.filter((issue) => issue.issue_type === 'epic')
|
||||
.sort((a, b) => {
|
||||
// Push closed epics to the end
|
||||
if (a.status === 'closed' && b.status !== 'closed') return 1;
|
||||
if (b.status === 'closed' && a.status !== 'closed') return -1;
|
||||
return a.id.localeCompare(b.id);
|
||||
}),
|
||||
[issues],
|
||||
);
|
||||
|
||||
// --- Derived data: tasks grouped by parent epic ---
|
||||
const tasksByEpic = useMemo(() => {
|
||||
const map = new Map<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 (epics.length === 0) {
|
||||
if (selectedEpicId !== null) {
|
||||
setSelectedEpicId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSelectedEpic = selectedEpicId ? epics.some((epic) => epic.id === selectedEpicId) : false;
|
||||
if (!hasSelectedEpic) {
|
||||
setSelectedEpicId(epics[0].id);
|
||||
}
|
||||
}, [epics, selectedEpicId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedTab === 'tasks' || requestedTab === 'dependencies') {
|
||||
setActiveTab(requestedTab);
|
||||
}
|
||||
}, [requestedTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedEpicId) return;
|
||||
if (!epics.some((epic) => epic.id === requestedEpicId)) return;
|
||||
setSelectedEpicId(requestedEpicId);
|
||||
}, [epics, requestedEpicId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedTaskId) {
|
||||
return;
|
||||
}
|
||||
if (!issues.some((issue) => issue.id === requestedTaskId)) {
|
||||
return;
|
||||
}
|
||||
setSelectedId(requestedTaskId);
|
||||
}, [issues, requestedTaskId]);
|
||||
|
||||
// If project scope changes and the selected task no longer exists, reset selection.
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
if (!issues.some((issue) => issue.id === selectedId)) {
|
||||
setSelectedId(null);
|
||||
}
|
||||
}, [issues, selectedId]);
|
||||
|
||||
// --- Derived: selected epic and its tasks ---
|
||||
const selectedEpic = useMemo(() => epics.find((epic) => epic.id === selectedEpicId) ?? null, [epics, selectedEpicId]);
|
||||
const projectLevelTasks = useMemo(
|
||||
() =>
|
||||
issues
|
||||
.filter((issue) => issue.issue_type !== 'epic')
|
||||
.filter((issue) => (!hideClosed ? true : issue.status !== 'closed'))
|
||||
.sort((a, b) => {
|
||||
const priorityDiff = a.priority - b.priority;
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
}),
|
||||
[hideClosed, issues],
|
||||
);
|
||||
|
||||
const selectedEpicTasks = useMemo(() => {
|
||||
const epicChildren = selectedEpic ? tasksByEpic.get(selectedEpic.id) ?? [] : [];
|
||||
if (epicChildren.length > 0) {
|
||||
return epicChildren;
|
||||
}
|
||||
|
||||
// Fallback: some projects have tasks but weak/missing parent links.
|
||||
// Keep the page usable by showing project-level tasks instead of a blank view.
|
||||
if (projectLevelTasks.length > 0) {
|
||||
return projectLevelTasks;
|
||||
}
|
||||
|
||||
// Last-resort fallback: if there are only epics, render epics as selectable items.
|
||||
return epics.filter((epic) => (!hideClosed ? true : epic.status !== 'closed'));
|
||||
}, [epics, hideClosed, projectLevelTasks, selectedEpic, tasksByEpic]);
|
||||
|
||||
const selectedEpicHasChildren = useMemo(() => {
|
||||
if (selectedEpic) {
|
||||
return (tasksByEpic.get(selectedEpic.id) ?? []).length > 0;
|
||||
}
|
||||
return false;
|
||||
}, [selectedEpic, tasksByEpic]);
|
||||
|
||||
// --- Auto-select best task when epic changes ---
|
||||
useEffect(() => {
|
||||
// Keep current selection if it remains visible in the current scope.
|
||||
if (selectedId && selectedEpicTasks.some((task) => task.id === selectedId)) {
|
||||
return;
|
||||
}
|
||||
const best = selectedEpicTasks.find((task) => task.status !== 'closed') ?? selectedEpicTasks[0] ?? null;
|
||||
if (best?.id !== selectedId) {
|
||||
setSelectedId(best?.id ?? null);
|
||||
}
|
||||
}, [selectedEpic, selectedEpicTasks, selectedId]);
|
||||
|
||||
// --- Graph model ---
|
||||
const graphModel = useMemo(() => buildGraphModel(issues, { projectKey: projectRoot }), [issues, projectRoot]);
|
||||
|
||||
// --- Signal map: blocker/blocks counts per issue ---
|
||||
const signalById = useMemo(() => {
|
||||
const map = new Map<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 issueById = new Map(issues.map((issue) => [issue.id, issue]));
|
||||
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: selectedId ? !chainNodeIds.has(issue.id) : false,
|
||||
blockerTooltipLines: externalBlockerNames.get(issue.id) ?? blockerTooltipMap.get(issue.id) ?? [],
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
type: 'flowNode',
|
||||
}));
|
||||
|
||||
const visibleIds = new Set(baseNodes.map((node) => node.id));
|
||||
const graphEdges: Edge[] = [];
|
||||
|
||||
// Search ALL issues for blocking edges between visible nodes.
|
||||
// Dependencies may be stored on issues outside visibleTasks but
|
||||
// still connect two nodes that are both visible in the graph.
|
||||
for (const issue of issues) {
|
||||
for (const dep of issue.dependencies) {
|
||||
// Both endpoints must be visible in the graph
|
||||
if (!visibleIds.has(issue.id) && !visibleIds.has(dep.target)) continue;
|
||||
if (!visibleIds.has(issue.id) || !visibleIds.has(dep.target)) continue;
|
||||
// Only show blocking edges (skip parent, relates_to, etc.)
|
||||
if (dep.type !== 'blocks') continue;
|
||||
// Avoid self-loops
|
||||
if (issue.id === dep.target) continue;
|
||||
const edgeId = `${issue.id}:blocks:${dep.target}`;
|
||||
|
||||
const linkedToSelection = selectedId ? issue.id === selectedId || dep.target === selectedId : false;
|
||||
|
||||
graphEdges.push({
|
||||
id: edgeId,
|
||||
source: issue.id,
|
||||
target: dep.target,
|
||||
className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted',
|
||||
animated: linkedToSelection,
|
||||
style: {
|
||||
stroke: linkedToSelection ? '#7dd3fc' : '#fbbf24',
|
||||
strokeWidth: linkedToSelection ? 2.5 : 1.8,
|
||||
opacity: linkedToSelection ? 1 : 0.55,
|
||||
},
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: linkedToSelection ? '#7dd3fc' : '#fbbf24', width: 14, height: 14 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: layoutDagre(baseNodes, graphEdges),
|
||||
edges: graphEdges,
|
||||
};
|
||||
}, [
|
||||
hideClosed, issues, selectedEpicTasks, selectedId,
|
||||
signalById, actionableNodeIds, cycleNodeIdSet,
|
||||
chainNodeIds, blockerTooltipMap, externalBlockerNames,
|
||||
]);
|
||||
|
||||
const nodeTypes: NodeTypes = useMemo(
|
||||
() => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
flowNode: GraphNodeCard as any,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// --- Handle node click in the graph (also opens detail drawer) ---
|
||||
const handleFlowNodeClick: NodeMouseHandler = useCallback((_, node) => {
|
||||
setSelectedId(node.id);
|
||||
setDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
// --- Default edge rendering options ---
|
||||
const defaultEdgeOptions = useMemo(
|
||||
() => ({
|
||||
type: 'smoothstep' as const,
|
||||
zIndex: 40,
|
||||
interactionWidth: 24,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// --- Handle task selection (opens drawer on Tasks tab) ---
|
||||
// If the target is in another epic or IS an epic, switch to that epic first.
|
||||
const handleTaskSelect = useCallback((id: string, shouldOpenDrawer = true) => {
|
||||
// 1. If task is already visible in current epic view, just select it
|
||||
if (selectedEpicTasks.some((t) => t.id === id)) {
|
||||
setSelectedId(id);
|
||||
if (shouldOpenDrawer) setDrawerOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If the target IS an epic itself, switch to that epic
|
||||
const targetIsEpic = epics.some((e) => e.id === id);
|
||||
if (targetIsEpic) {
|
||||
setSelectedEpicId(id);
|
||||
// Select the epic itself so the drawer shows its details
|
||||
setSelectedId(id);
|
||||
if (shouldOpenDrawer) setDrawerOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Target is a task in another epic -- find which epic owns it
|
||||
const targetIssue = issues.find((i) => i.id === id);
|
||||
if (targetIssue) {
|
||||
// Determine parent epic: explicit parent dependency, or convention (id prefix before first dot)
|
||||
const parentDep = targetIssue.dependencies.find((dep) => dep.type === 'parent');
|
||||
const epicId = parentDep?.target ?? (targetIssue.id.includes('.') ? targetIssue.id.split('.')[0] : null);
|
||||
|
||||
if (epicId && epicId !== selectedEpicId) {
|
||||
const epicExists = epics.some((e) => e.id === epicId);
|
||||
if (epicExists) {
|
||||
// If the target is closed and we are hiding closed tasks, unhide so we can see it
|
||||
if (targetIssue.status === 'closed' && hideClosed) {
|
||||
setHideClosed(false);
|
||||
}
|
||||
|
||||
setSelectedEpicId(epicId);
|
||||
setSelectedId(id);
|
||||
if (shouldOpenDrawer) setDrawerOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback: select the id directly (might be orphan)
|
||||
setSelectedId(id);
|
||||
if (shouldOpenDrawer) setDrawerOpen(true);
|
||||
}, [selectedEpicTasks, selectedEpicId, issues, epics, hideClosed]);
|
||||
|
||||
// --- Handle drawer close ---
|
||||
const handleDrawerClose = useCallback(() => {
|
||||
setDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-[1880px] px-4 py-4 sm:px-6 sm:py-6 lg:px-10">
|
||||
{/* Page header */}
|
||||
<header className="mb-6 rounded-3xl border border-white/5 bg-[radial-gradient(circle_at_2%_2%,rgba(56,189,248,0.12),transparent_40%),linear-gradient(170deg,rgba(15,23,42,0.92),rgba(11,12,16,0.95))] px-5 py-5 sm:px-8 sm:py-8 shadow-[0_32px_64px_-16px_rgba(0,0,0,0.6)] backdrop-blur-2xl">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-sky-400/70 font-bold">BeadBoard Workspace</p>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text-strong sm:text-4xl">Workflow Explorer</h1>
|
||||
<Link href={kanbanHref} className="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>
|
||||
</div>
|
||||
<p className="hidden max-w-md text-sm leading-relaxed text-text-muted/90 sm:block">
|
||||
Epic-driven dependency visualization. Drill into task relationships, triage blockers, and understand downstream impact at a glance.
|
||||
</p>
|
||||
</div>
|
||||
{activeScope ? (
|
||||
<p className="mt-3 text-xs text-text-muted/90">
|
||||
Scope:{' '}
|
||||
<span className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 font-mono text-[11px] text-text-body">
|
||||
{activeScope.source === 'local' ? 'local workspace' : activeScope.displayPath}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<ProjectScopeControls
|
||||
projectScopeKey={projectScopeKey}
|
||||
projectScopeMode={projectScopeMode}
|
||||
projectScopeOptions={projectScopeOptions}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 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={epics}
|
||||
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={epics}
|
||||
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}
|
||||
signalById={signalById}
|
||||
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}
|
||||
signalById={signalById}
|
||||
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}
|
||||
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={() => router.refresh()}
|
||||
blockedTree={selectedIssue ? buildBlockedByTree(issues, selectedIssue.id) : undefined}
|
||||
outgoingBlocks={selectedId ? blocksDetailsMap.get(selectedId) ?? [] : []}
|
||||
onSelectBlockedIssue={handleTaskSelect}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
139
src/components/graph/epic-chip-strip.tsx
Normal file
139
src/components/graph/epic-chip-strip.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Chip } from '../shared/chip';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
/** Props for the EpicChipStrip component. */
|
||||
interface EpicChipStripProps {
|
||||
/** List of all epic issues to display as selectable chips. */
|
||||
epics: BeadIssue[];
|
||||
/** Currently selected epic ID, or null if none selected. */
|
||||
selectedEpicId: string | null;
|
||||
/** Map of epic ID to total bead (task) count. */
|
||||
beadCounts: Map<string, number>;
|
||||
/** Callback fired when the user clicks an epic chip. */
|
||||
onSelect: (epicId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the label and color for an epic's status.
|
||||
*/
|
||||
function statusStyle(status: BeadIssue['status']): { label: string; dot: string } {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return { label: 'Open', dot: 'bg-sky-400' };
|
||||
case 'in_progress':
|
||||
return { label: 'In Progress', dot: 'bg-amber-400' };
|
||||
case 'blocked':
|
||||
return { label: 'Blocked', dot: 'bg-rose-500' };
|
||||
case 'closed':
|
||||
return { label: 'Done', dot: 'bg-emerald-400' };
|
||||
case 'deferred':
|
||||
return { label: 'Deferred', dot: 'bg-slate-400' };
|
||||
default:
|
||||
return { label: status, dot: 'bg-zinc-500' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an epic selector as a dropdown button that expands an inline selection panel.
|
||||
* When collapsed: shows the selected epic's title as a button.
|
||||
* When expanded: shows a horizontal strip of epic cards with ID, title, and status,
|
||||
* pushing page content down naturally.
|
||||
*/
|
||||
export function EpicChipStrip({ epics, selectedEpicId, beadCounts, onSelect }: EpicChipStripProps) {
|
||||
// Track whether the epic selector panel is expanded
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Find the currently selected epic for the button label
|
||||
const selectedEpic = epics.find((epic) => epic.id === selectedEpicId);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Collapsed state: button showing selected epic */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
className="flex items-center gap-2.5 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2 text-left transition-all hover:bg-white/[0.07] hover:border-white/15 active:scale-[0.98] w-full"
|
||||
>
|
||||
{/* Status dot */}
|
||||
{selectedEpic ? (
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${statusStyle(selectedEpic.status).dot}`} />
|
||||
) : null}
|
||||
|
||||
{/* Selected epic label */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-bold uppercase tracking-wider text-text-muted/50">
|
||||
Epic
|
||||
</span>
|
||||
<span className="block truncate text-sm font-semibold text-text-strong">
|
||||
{selectedEpic ? selectedEpic.title : 'Select an epic'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
<span className="text-text-muted/50 text-sm shrink-0">
|
||||
{expanded ? '\u25b2' : '\u25bc'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded state: horizontal card strip */}
|
||||
{expanded ? (
|
||||
<div className="mt-2 rounded-2xl border border-white/8 bg-[#0c0e14]/95 p-3 shadow-[0_16px_48px_rgba(0,0,0,0.5)] backdrop-blur-lg animate-fade-in">
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
|
||||
{epics.map((epic) => {
|
||||
// Determine if this card is the currently selected epic
|
||||
const isSelected = epic.id === selectedEpicId;
|
||||
// Closed epics get a muted visual treatment
|
||||
const isClosed = epic.status === 'closed';
|
||||
const style = statusStyle(epic.status);
|
||||
const count = beadCounts.get(epic.id) ?? 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={epic.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(epic.id);
|
||||
setExpanded(false);
|
||||
}}
|
||||
className={`flex flex-col gap-2 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${isSelected
|
||||
? 'border-sky-400/40 bg-sky-400/10 ring-1 ring-sky-400/15'
|
||||
: isClosed
|
||||
? 'border-white/5 bg-white/[0.02] opacity-50 hover:opacity-80'
|
||||
: 'border-white/8 bg-white/[0.03] hover:bg-white/[0.06] hover:border-white/15'
|
||||
}`}
|
||||
>
|
||||
{/* Top row: ID + Status + Priority */}
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<span className="font-mono text-[9px] uppercase tracking-wider text-text-muted/60">{epic.id}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1 rounded-md bg-white/5 px-1.5 py-0.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-text-muted/70">{style.label}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-amber-400/80 bg-amber-400/10 px-1.5 py-0.5 rounded">P{epic.priority}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Epic title */}
|
||||
<p className={`text-[12px] font-semibold leading-tight text-text-strong line-clamp-2 ${isClosed ? 'line-through' : ''}`}>
|
||||
{epic.title}
|
||||
</p>
|
||||
|
||||
{/* Metadata Row: Bead Count */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-text-muted bg-white/5 px-2 py-0.5 rounded-full border border-white/5">
|
||||
{count} {count === 1 ? 'bead' : 'beads'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
src/components/graph/graph-node-card.tsx
Normal file
199
src/components/graph/graph-node-card.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
/** Data payload for each custom ReactFlow node. */
|
||||
export interface GraphNodeData {
|
||||
/** Index signature required by ReactFlow's Node<Record<string, unknown>> constraint. */
|
||||
[key: string]: unknown;
|
||||
/** Display title of the task/epic. */
|
||||
title: string;
|
||||
/** Whether this is an epic or a regular issue. */
|
||||
kind: 'epic' | 'issue';
|
||||
/** Current workflow status. */
|
||||
status: BeadIssue['status'];
|
||||
/** Priority level (0 = highest). */
|
||||
priority: number;
|
||||
/** Number of issues blocking this node. */
|
||||
blockedBy: number;
|
||||
/** Number of issues this node blocks. */
|
||||
blocks: number;
|
||||
/** Whether this node has zero open blockers and is actionable. */
|
||||
isActionable: boolean;
|
||||
/** Whether this node is part of a dependency cycle. */
|
||||
isCycleNode: boolean;
|
||||
/** Whether this node should appear dimmed (not in selected chain). */
|
||||
isDimmed: boolean;
|
||||
/** Tooltip lines describing blocker details for hover display. */
|
||||
blockerTooltipLines: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Tailwind background color class for a status dot indicator.
|
||||
*/
|
||||
function statusDot(status: BeadIssue['status']): string {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'bg-sky-400';
|
||||
case 'in_progress':
|
||||
return 'bg-amber-400';
|
||||
case 'blocked':
|
||||
return 'bg-rose-500';
|
||||
case 'deferred':
|
||||
return 'bg-slate-400';
|
||||
case 'closed':
|
||||
return 'bg-emerald-400';
|
||||
case 'pinned':
|
||||
return 'bg-violet-400';
|
||||
case 'hooked':
|
||||
return 'bg-orange-400';
|
||||
default:
|
||||
return 'bg-zinc-500';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base card style class based on the node kind (epic vs issue).
|
||||
*/
|
||||
function nodeStyle(kind: GraphNodeData['kind']): string {
|
||||
return kind === 'epic'
|
||||
? 'bg-[linear-gradient(160deg,rgba(56,189,248,0.06),rgba(15,23,42,0.9))] border-sky-400/15'
|
||||
: 'bg-[linear-gradient(160deg,rgba(255,255,255,0.03),rgba(15,23,42,0.85))] border-white/8';
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom ReactFlow node component with:
|
||||
* - Status-aware styling (green glow for actionable, red ring for cycles)
|
||||
* - Hover tooltip showing blocker details or "Ready to work"
|
||||
* - Pulse animation on selection
|
||||
* - Dim effect when not in the selected dependency chain
|
||||
*/
|
||||
export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeData>>) {
|
||||
// Track hover state for tooltip visibility
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Target handle for incoming edges (from the left) */}
|
||||
<Handle type="target" position={Position.Left} className="!opacity-0" />
|
||||
|
||||
{/* Main card body */}
|
||||
<div
|
||||
className={`group w-[18.5rem] rounded-xl border px-3 py-3 text-left transition-all duration-300 ${nodeStyle(data.kind)} ${
|
||||
// Status-based left border accent for visual scanning
|
||||
data.status === 'in_progress' ? 'border-l-2 border-l-amber-400/60' :
|
||||
data.status === 'blocked' ? 'border-l-2 border-l-rose-500/60' :
|
||||
data.status === 'closed' ? 'border-l-2 border-l-emerald-400/40 opacity-60' : ''
|
||||
} ${
|
||||
// Cycle detection ring
|
||||
data.isCycleNode ? 'ring-2 ring-rose-400/55' : ''
|
||||
} ${
|
||||
// Actionable / "ready to work" glow effect
|
||||
data.isActionable && !selected
|
||||
? 'ring-1 ring-emerald-400/30 shadow-[0_0_20px_rgba(16,185,129,0.12)]'
|
||||
: ''
|
||||
} ${
|
||||
// Selected state with pulse animation
|
||||
selected
|
||||
? 'border-sky-400/50 shadow-[0_20px_48px_-8px_rgba(0,0,0,0.5)] ring-1 ring-sky-400/20 node-select-pulse'
|
||||
: 'hover:border-white/20 hover:shadow-[0_8px_32px_-4px_rgba(0,0,0,0.3)]'
|
||||
} ${
|
||||
// Dim effect for nodes not in the selected chain
|
||||
data.isDimmed ? 'opacity-30' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
{/* Header: ID + priority + status badges */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-white/5 pb-1.5 mb-1.5">
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-text-muted/60">{id}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* "READY" badge for actionable nodes */}
|
||||
{data.isActionable ? (
|
||||
<span className="rounded-md bg-emerald-500/15 px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-emerald-400 ring-1 ring-emerald-500/20">
|
||||
Ready
|
||||
</span>
|
||||
) : null}
|
||||
{/* Status badge: IN PROGRESS, BLOCKED, DONE */}
|
||||
{data.status === 'in_progress' ? (
|
||||
<span className="rounded-md bg-amber-400/10 px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-amber-400">
|
||||
In Progress
|
||||
</span>
|
||||
) : data.status === 'blocked' ? (
|
||||
<span className="rounded-md bg-rose-400/10 px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-rose-400">
|
||||
Blocked
|
||||
</span>
|
||||
) : data.status === 'closed' ? (
|
||||
<span className="rounded-md bg-emerald-400/10 px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-emerald-400">
|
||||
Done
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-text-muted/40">p{data.priority}</span>
|
||||
<span className={`h-2 w-2 rounded-full ring-2 ring-black/40 ${statusDot(data.status)}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title - strikethrough for closed tasks */}
|
||||
<p className={`text-[15px] font-bold leading-[1.2] tracking-tight text-text-strong group-hover:text-sky-100 transition-colors ${data.status === 'closed' ? 'line-through opacity-70' : ''}`}>
|
||||
{data.title}
|
||||
</p>
|
||||
|
||||
{/* Footer: show blocker names for blocked tasks, click hint for others */}
|
||||
{data.blockerTooltipLines.length > 0 ? (
|
||||
<div className="mt-2 border-t border-white/5 pt-1.5">
|
||||
<p className="text-[8px] font-bold uppercase tracking-widest text-rose-400/70 mb-0.5">Waiting on</p>
|
||||
{data.blockerTooltipLines.slice(0, 2).map((line) => (
|
||||
<p key={line} className="text-[9px] text-text-muted/70 truncate leading-tight">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
{data.blockerTooltipLines.length > 2 ? (
|
||||
<p className="text-[8px] text-text-muted/50">
|
||||
+{data.blockerTooltipLines.length - 2} more
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Tooltip: shown on hover with 300ms CSS delay */}
|
||||
{hovered ? (
|
||||
<div className="absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 animate-fade-in">
|
||||
<div className="max-w-xs rounded-lg border border-white/10 bg-[#0d0f14]/95 px-3 py-2 shadow-[0_12px_32px_rgba(0,0,0,0.6)] backdrop-blur-lg">
|
||||
{data.isActionable ? (
|
||||
<>
|
||||
<p className="text-[10px] font-bold text-emerald-400">Ready to work</p>
|
||||
<p className="mt-0.5 text-[10px] text-text-muted/80">
|
||||
No open blockers. {data.blocks} task{data.blocks === 1 ? '' : 's'} depend{data.blocks === 1 ? 's' : ''} on this.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[10px] font-bold text-rose-400">
|
||||
Blocked by {data.blockedBy} task{data.blockedBy === 1 ? '' : 's'}
|
||||
</p>
|
||||
{data.blockerTooltipLines.length > 0 ? (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{data.blockerTooltipLines.map((line) => (
|
||||
<li key={line} className="text-[9px] text-text-muted/80">
|
||||
• {line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Source handle for outgoing edges (to the right) */}
|
||||
<Handle type="source" position={Position.Right} className="!opacity-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/graph/graph-section.tsx
Normal file
111
src/components/graph/graph-section.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
Background,
|
||||
ReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeMouseHandler,
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react';
|
||||
|
||||
import type { BlockedChainAnalysis } from '../../lib/graph-view';
|
||||
import type { GraphNodeData } from './graph-node-card';
|
||||
|
||||
/** Props for the GraphSection component. */
|
||||
interface GraphSectionProps {
|
||||
/** ReactFlow nodes with layout positions applied. */
|
||||
nodes: Node<GraphNodeData>[];
|
||||
/** ReactFlow edges connecting the nodes. */
|
||||
edges: Edge[];
|
||||
/** Map of custom node type names to their React components. */
|
||||
nodeTypes: NodeTypes;
|
||||
/** Default edge rendering options. */
|
||||
defaultEdgeOptions: {
|
||||
type: 'smoothstep';
|
||||
zIndex: number;
|
||||
interactionWidth: number;
|
||||
};
|
||||
/** Callback fired when a node is clicked in the graph. */
|
||||
onNodeClick: NodeMouseHandler;
|
||||
/** Optional blocker summary for the currently selected task. */
|
||||
blockerAnalysis?: BlockedChainAnalysis | null;
|
||||
/** Whether closed items are hidden from the graph workspace. */
|
||||
hideClosed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the ReactFlow graph with status-lane layout.
|
||||
* Shows a compact legend and full graph viewport.
|
||||
* Nodes are positioned in columns by status: Done | In Progress | Ready | Blocked.
|
||||
*/
|
||||
export function GraphSection({
|
||||
nodes,
|
||||
edges,
|
||||
nodeTypes,
|
||||
defaultEdgeOptions,
|
||||
onNodeClick,
|
||||
blockerAnalysis,
|
||||
hideClosed = false,
|
||||
}: GraphSectionProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Compact legend + tip */}
|
||||
<div className="workflow-graph-legend flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/5 bg-white/[0.02] px-3 py-2">
|
||||
<p className="text-[10px] text-text-muted/60">
|
||||
<span className="font-bold uppercase tracking-[0.15em]">Legend</span>
|
||||
{' '}
|
||||
{!hideClosed ? (
|
||||
<>
|
||||
<span className="text-emerald-400">Done</span>
|
||||
{' \u2192 '}
|
||||
</>
|
||||
) : null}
|
||||
<span className="text-amber-400">In Progress</span>
|
||||
{' \u2192 '}
|
||||
<span className="text-cyan-400">Ready</span>
|
||||
{' \u2192 '}
|
||||
<span className="text-rose-400">Blocked</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-text-muted/40">
|
||||
Click a task to see details •{' '}
|
||||
<span className="inline-block h-1 w-4 rounded bg-amber-400 align-middle" /> = blocks
|
||||
</p>
|
||||
{blockerAnalysis ? (
|
||||
<p className="text-[10px] text-text-muted/60">
|
||||
Open blockers: {blockerAnalysis.openBlockerCount}
|
||||
{' | '}
|
||||
In progress blockers: {blockerAnalysis.inProgressBlockerCount}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ReactFlow graph viewport */}
|
||||
<div className="relative h-[60vh] min-h-[35rem] overflow-hidden rounded-2xl border border-white/5 bg-[radial-gradient(circle_at_50%_50%,rgba(15,23,42,0.4),rgba(5,8,15,0.8))] shadow-inner">
|
||||
<ReactFlow
|
||||
className="workflow-graph-flow"
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
minZoom={0.3}
|
||||
maxZoom={1.5}
|
||||
translateExtent={[
|
||||
[-500, -500],
|
||||
[3000, 2500],
|
||||
]}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable
|
||||
onlyRenderVisibleElements
|
||||
onNodeClick={onNodeClick}
|
||||
>
|
||||
<Background gap={32} size={1} color="rgba(255,255,255,0.03)" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
366
src/components/graph/task-card-grid.tsx
Normal file
366
src/components/graph/task-card-grid.tsx
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
'use client';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
/** Props for an individual task card in the grid. */
|
||||
/** Details for a blocker task shown on the card. */
|
||||
export interface BlockerDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
status: BeadIssue['status'];
|
||||
priority: BeadIssue['priority'];
|
||||
epicTitle?: string;
|
||||
}
|
||||
|
||||
/** Props for an individual task card in the grid. */
|
||||
interface TaskCardProps {
|
||||
/** The issue data for this card. */
|
||||
issue: BeadIssue;
|
||||
/** Whether this card is the currently selected task. */
|
||||
selected: boolean;
|
||||
/** Number of issues blocking this task. */
|
||||
blockedBy: number;
|
||||
/** Number of issues this task blocks. */
|
||||
blocks: number;
|
||||
/** List of issues blocking this task. */
|
||||
blockers: BlockerDetail[];
|
||||
/** List of issues this task blocks. */
|
||||
blocking: BlockerDetail[];
|
||||
/** Whether this task is actionable (unblocked). */
|
||||
isActionable: boolean;
|
||||
/** Callback fired when the user clicks this card (or a blocker). */
|
||||
onSelect: (id: string, shouldOpenDrawer?: boolean) => void;
|
||||
}
|
||||
|
||||
/** Props for the TaskCardGrid component. */
|
||||
interface TaskCardGridProps {
|
||||
/** List of tasks to display in the grid. */
|
||||
tasks: BeadIssue[];
|
||||
/** ID of the currently selected task, or null. */
|
||||
selectedId: string | null;
|
||||
/** Map of issue ID to blocker/blocks counts. */
|
||||
signalById: Map<string, { blockedBy: number; blocks: number }>;
|
||||
/** Map of issue ID to detailed blocker info. */
|
||||
blockerDetailsMap: Map<string, BlockerDetail[]>;
|
||||
/** Map of issue ID to detailed downstream blocking info. */
|
||||
blocksDetailsMap: Map<string, BlockerDetail[]>;
|
||||
/** Set of actionable (unblocked) task IDs. */
|
||||
actionableIds: Set<string>;
|
||||
/** Callback fired when the user selects a task. */
|
||||
onSelect: (id: string, shouldOpenDrawer?: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Tailwind background color class for a status dot indicator.
|
||||
* Mirrors the statusDot function from the original monolith.
|
||||
*/
|
||||
function statusDot(status: BeadIssue['status']): string {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'bg-sky-400';
|
||||
case 'in_progress':
|
||||
return 'bg-amber-400';
|
||||
case 'blocked':
|
||||
return 'bg-rose-500';
|
||||
case 'deferred':
|
||||
return 'bg-slate-400';
|
||||
case 'closed':
|
||||
return 'bg-emerald-400';
|
||||
case 'pinned':
|
||||
return 'bg-violet-400';
|
||||
case 'hooked':
|
||||
return 'bg-orange-400';
|
||||
default:
|
||||
return 'bg-zinc-500';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-friendly label and text color class for a status.
|
||||
*/
|
||||
function statusBadge(status: BeadIssue['status'], isActionable: boolean, hasBlockers: boolean): { label: string; textColor: string; bgColor: string } {
|
||||
// If effectively blocked (has open blockers), show Blocked (unless closed/done)
|
||||
if (hasBlockers && status !== 'closed' && status !== 'in_progress') {
|
||||
return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' };
|
||||
}
|
||||
|
||||
// Special case: "Blocked Now Open" -> Ready
|
||||
if (status === 'blocked' && isActionable) {
|
||||
return { label: 'Ready', textColor: 'text-cyan-400', bgColor: 'bg-cyan-400/10' };
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'in_progress':
|
||||
return { label: 'In Progress', textColor: 'text-amber-400', bgColor: 'bg-amber-400/10' };
|
||||
case 'blocked':
|
||||
return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' };
|
||||
case 'closed':
|
||||
return { label: 'Done', textColor: 'text-emerald-400', bgColor: 'bg-emerald-400/10' };
|
||||
case 'deferred':
|
||||
return { label: 'Deferred', textColor: 'text-slate-400', bgColor: 'bg-slate-400/10' };
|
||||
case 'open':
|
||||
// Open with no blockers -> Ready
|
||||
return { label: 'Ready', textColor: 'text-cyan-400', bgColor: 'bg-cyan-400/10' };
|
||||
default:
|
||||
return { label: status, textColor: 'text-zinc-400', bgColor: 'bg-zinc-400/10' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a card-level border class based on status for visual distinction.
|
||||
*/
|
||||
function statusBorder(status: BeadIssue['status'], isActionable: boolean, hasBlockers: boolean): string {
|
||||
if (hasBlockers && status !== 'closed' && status !== 'in_progress') {
|
||||
return 'border-l-2 border-l-rose-500/60';
|
||||
}
|
||||
if (status === 'blocked' && isActionable) {
|
||||
return 'border-l-2 border-l-cyan-400/60';
|
||||
}
|
||||
if (status === 'open') {
|
||||
return 'border-l-2 border-l-cyan-400/60';
|
||||
}
|
||||
switch (status) {
|
||||
case 'in_progress':
|
||||
return 'border-l-2 border-l-amber-400/60';
|
||||
case 'blocked':
|
||||
return 'border-l-2 border-l-rose-500/60';
|
||||
case 'closed':
|
||||
return 'border-l-2 border-l-emerald-400/40 opacity-60';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single task card displaying the issue ID, title, priority, type, assignee,
|
||||
* and detailed blocker list (interactive).
|
||||
*/
|
||||
function TaskCard({ issue, selected, blockedBy, blocks, blockers, blocking, isActionable, onSelect }: TaskCardProps) {
|
||||
const hasBlockers = blockers.length > 0; // Note: blockers list only contains OPEN blockers (computed in page)
|
||||
const badge = statusBadge(issue.status, isActionable, hasBlockers);
|
||||
const projectName = (issue as BeadIssue & { project?: { name?: string } }).project?.name ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(issue.id, false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(issue.id, false);
|
||||
}
|
||||
}}
|
||||
className={`workflow-card group relative flex w-full flex-col rounded-xl px-4 py-4 text-left transition duration-200 ${statusBorder(
|
||||
issue.status,
|
||||
isActionable,
|
||||
hasBlockers,
|
||||
)} ${selected
|
||||
? 'workflow-card-selected'
|
||||
: 'hover:border-sky-300/40 hover:bg-[linear-gradient(165deg,rgba(76,94,134,0.2),rgba(18,20,30,0.84))]'
|
||||
}`}
|
||||
>
|
||||
{/* Expand / Open Drawer Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 z-10 rounded p-1.5 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(issue.id, true);
|
||||
}}
|
||||
title="Open Details"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
||||
</button>
|
||||
|
||||
<div className="flex w-full items-start justify-between gap-3 pr-6">
|
||||
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${statusDot(issue.status)} ring-1 ring-white/10`} />
|
||||
<span className="font-mono text-[10px] text-text-muted">{issue.id}</span>
|
||||
{/* Status Badge */}
|
||||
<span className={`rounded px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${badge.textColor} ${badge.bgColor}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
{projectName ? (
|
||||
<div className="inline-flex w-fit rounded border border-sky-300/25 bg-sky-500/10 px-1.5 py-0.5 font-mono text-[9px] text-sky-200">
|
||||
project: {projectName}
|
||||
</div>
|
||||
) : null}
|
||||
<h3 className="line-clamp-3 text-sm font-medium leading-snug text-text-strong">
|
||||
{issue.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{issue.labels?.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{issue.labels.map((label) => (
|
||||
<span key={label} className="rounded bg-white/5 px-1.5 py-0.5 text-[9px] font-medium text-text-muted/80 backdrop-blur-sm border border-white/5">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* "Waiting On" section for blockers */}
|
||||
{blockers.length > 0 ? (
|
||||
<div className="mt-auto pt-2 w-full">
|
||||
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
|
||||
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-rose-400/80">Waiting On</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{blockers.map((blocker) => (
|
||||
<div
|
||||
key={blocker.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(blocker.id, false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
onSelect(blocker.id, false);
|
||||
}
|
||||
}}
|
||||
className="group relative flex flex-col gap-0.5 rounded border border-white/5 bg-white/5 px-2.5 py-2 hover:border-sky-400/30 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{/* Expand Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1 z-10 rounded p-1 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(blocker.id, true);
|
||||
}}
|
||||
title="Open Details"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 pr-5">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${statusDot(blocker.status)}`} />
|
||||
<span className="font-mono text-[9px] text-text-muted">{blocker.id}</span>
|
||||
<span className="line-clamp-1 text-[10px] font-medium text-text-body">{blocker.title}</span>
|
||||
</div>
|
||||
{blocker.epicTitle ? (
|
||||
<div className="pl-3.5 text-[9px] text-text-muted/60 truncate max-w-full pr-5">
|
||||
<span className="group-hover:text-sky-300/70 transition-colors">↳ {blocker.epicTitle}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* "Blocking" section (downstream) */}
|
||||
{blocking.length > 0 ? (
|
||||
<div className={`${blockers.length > 0 ? 'mt-2' : 'mt-auto'} w-full`}>
|
||||
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
|
||||
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-amber-400/80">Blocking</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{blocking.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(item.id, false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
onSelect(item.id, false);
|
||||
}
|
||||
}}
|
||||
className="group relative flex flex-col gap-0.5 rounded border border-white/5 bg-white/5 px-2.5 py-2 hover:border-sky-400/30 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{/* Expand Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1 z-10 rounded p-1 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(item.id, true);
|
||||
}}
|
||||
title="Open Details"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 pr-5">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${statusDot(item.status)}`} />
|
||||
<span className="font-mono text-[9px] text-text-muted">{item.id}</span>
|
||||
<span className="line-clamp-1 text-[10px] font-medium text-text-body">{item.title}</span>
|
||||
</div>
|
||||
{item.epicTitle ? (
|
||||
<div className="pl-3.5 text-[9px] text-text-muted/60 truncate max-w-full pr-5">
|
||||
<span className="group-hover:text-sky-300/70 transition-colors">↳ {item.epicTitle}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Footer Metadata: Assignee, Due Date */}
|
||||
<div className={`mt-3 flex w-full items-center justify-between border-t border-white/5 pt-3 text-[10px] text-text-muted/60`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Assignee */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="i-lucide-user h-3 w-3 opacity-70" />
|
||||
<span>{issue.assignee ?? 'Unassigned'}</span>
|
||||
</div>
|
||||
{/* Due Date (if exists) */}
|
||||
{issue.due_at ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="i-lucide-calendar h-3 w-3 opacity-70" />
|
||||
<span>{new Date(issue.due_at as string).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a responsive grid of task cards.
|
||||
* Uses auto-fill with minmax to prevent cards from being too narrow to read.
|
||||
*/
|
||||
export function TaskCardGrid({ tasks, selectedId, signalById, blockerDetailsMap, blocksDetailsMap, actionableIds, onSelect }: TaskCardGridProps) {
|
||||
// Show an empty state when no tasks exist in the selected epic
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-6 py-12 text-center">
|
||||
<p className="text-xs font-medium uppercase tracking-widest text-text-muted/50">No tasks in this epic</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 overflow-y-auto overscroll-contain pr-1 custom-scrollbar grid-cols-[repeat(auto-fill,minmax(18rem,1fr))]">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
issue={task}
|
||||
selected={selectedId === task.id}
|
||||
blockedBy={signalById.get(task.id)?.blockedBy ?? 0}
|
||||
blocks={signalById.get(task.id)?.blocks ?? 0}
|
||||
blockers={blockerDetailsMap?.get(task.id) ?? []}
|
||||
blocking={blocksDetailsMap?.get(task.id) ?? []}
|
||||
isActionable={actionableIds?.has(task.id) ?? false}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
src/components/graph/task-details-drawer.tsx
Normal file
117
src/components/graph/task-details-drawer.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { BlockedTreeNode } from '../../lib/kanban';
|
||||
import { KanbanDetail } from '../kanban/kanban-detail';
|
||||
|
||||
/** Props for the TaskDetailsDrawer component. */
|
||||
interface TaskDetailsDrawerProps {
|
||||
/** The issue to display, or null if nothing is selected. */
|
||||
issue: BeadIssue | null;
|
||||
/** Whether the drawer is open (visible). */
|
||||
open: boolean;
|
||||
/** Callback fired when the user closes the drawer. */
|
||||
onClose: () => void;
|
||||
/** Project root for mutation requests. */
|
||||
projectRoot?: string;
|
||||
/** Whether editing is enabled for the drawer. */
|
||||
editable?: boolean;
|
||||
/** Callback fired after successful save. */
|
||||
onIssueUpdated?: (issueId: string) => Promise<void> | void;
|
||||
|
||||
/** Tree of blocked issues (incoming). */
|
||||
blockedTree?: { total: number; nodes: BlockedTreeNode[] };
|
||||
/** List of issues blocked by this one (outgoing). */
|
||||
outgoingBlocks?: { id: string; title: string; status: string }[];
|
||||
/** Callback when a blocked/blocking issue is clicked. */
|
||||
onSelectBlockedIssue?: (issueId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slide-in drawer panel from the right side that shows full task details.
|
||||
* Opens when a task is selected, closes via the X button or clicking the backdrop.
|
||||
* Uses CSS translate for the slide animation.
|
||||
*/
|
||||
export function TaskDetailsDrawer({
|
||||
issue,
|
||||
open,
|
||||
onClose,
|
||||
projectRoot,
|
||||
editable = true,
|
||||
onIssueUpdated,
|
||||
blockedTree,
|
||||
outgoingBlocks,
|
||||
onSelectBlockedIssue
|
||||
}: TaskDetailsDrawerProps) {
|
||||
// Reference for the drawer panel to manage focus trapping
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close drawer on Escape key press
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay - click to close */}
|
||||
<div
|
||||
className={`fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity duration-300 ${open ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer panel - slides in from right */}
|
||||
<div
|
||||
ref={drawerRef}
|
||||
className={`fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-white/10 bg-[#0b0c10]/95 backdrop-blur-xl shadow-[-32px_0_64px_rgba(0,0,0,0.5)] transition-transform duration-300 ease-out ${open ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Drawer header with close button */}
|
||||
<div className="flex items-center justify-between border-b border-white/5 bg-white/[0.02] px-6 py-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">Task Details</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1 text-xs font-bold text-text-body transition-all hover:bg-white/10 active:scale-95"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-6 custom-scrollbar">
|
||||
{issue ? (
|
||||
<KanbanDetail
|
||||
issue={issue}
|
||||
framed={false}
|
||||
projectRoot={projectRoot}
|
||||
editable={editable}
|
||||
onIssueUpdated={onIssueUpdated}
|
||||
blockedTree={blockedTree}
|
||||
outgoingBlocks={outgoingBlocks}
|
||||
onSelectBlockedIssue={onSelectBlockedIssue}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-xs font-medium uppercase tracking-widest text-text-muted/50">
|
||||
Select a task to view details
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
src/components/graph/workflow-tabs.tsx
Normal file
47
src/components/graph/workflow-tabs.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
'use client';
|
||||
|
||||
/** The two available view tabs in the Workflow Explorer. */
|
||||
export type WorkflowTab = 'tasks' | 'dependencies';
|
||||
|
||||
/** Props for the WorkflowTabs component. */
|
||||
interface WorkflowTabsProps {
|
||||
/** The currently active tab. */
|
||||
activeTab: WorkflowTab;
|
||||
/** Callback fired when the user switches tabs. */
|
||||
onTabChange: (tab: WorkflowTab) => void;
|
||||
}
|
||||
|
||||
/** Tab label and key pairs for rendering. */
|
||||
const TAB_OPTIONS: { key: WorkflowTab; label: string }[] = [
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
{ key: 'dependencies', label: 'Dependencies' },
|
||||
];
|
||||
|
||||
/**
|
||||
* A two-tab switcher for toggling between the Tasks view and Dependencies view.
|
||||
* Uses a pill-style indicator that slides to the active tab.
|
||||
*/
|
||||
export function WorkflowTabs({ activeTab, onTabChange }: WorkflowTabsProps) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1 rounded-xl border border-white/8 bg-white/[0.02] p-1">
|
||||
{TAB_OPTIONS.map((tab) => {
|
||||
// Determine if this tab is currently active
|
||||
const isActive = activeTab === tab.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
className={`rounded-lg px-4 py-1.5 text-xs font-bold uppercase tracking-wider transition-all duration-200 ${isActive
|
||||
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
|
||||
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
src/components/shared/project-scope-controls.tsx
Normal file
322
src/components/shared/project-scope-controls.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import type { ProjectScopeMode, ProjectScopeOption } from '../../lib/project-scope';
|
||||
|
||||
interface ScannerProject {
|
||||
key: string;
|
||||
displayPath: string;
|
||||
}
|
||||
|
||||
interface ScannerPayload {
|
||||
projects: ScannerProject[];
|
||||
stats: {
|
||||
scannedDirectories: number;
|
||||
ignoredDirectories: number;
|
||||
skippedDirectories: number;
|
||||
elapsedMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectScopeControlsProps {
|
||||
projectScopeKey: string;
|
||||
projectScopeMode: ProjectScopeMode;
|
||||
projectScopeOptions: ProjectScopeOption[];
|
||||
}
|
||||
|
||||
function buildHref(pathname: string, mode: ProjectScopeMode, key: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (mode !== 'single') {
|
||||
params.set('mode', mode);
|
||||
}
|
||||
if (key !== 'local') {
|
||||
params.set('project', key);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `${pathname}?${query}` : pathname;
|
||||
}
|
||||
|
||||
export function ProjectScopeControls({
|
||||
projectScopeKey,
|
||||
projectScopeMode,
|
||||
projectScopeOptions,
|
||||
}: ProjectScopeControlsProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [addPath, setAddPath] = useState('');
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<ScannerPayload | null>(null);
|
||||
const [scanMode, setScanMode] = useState<'default' | 'full-drive'>('default');
|
||||
|
||||
const selected = useMemo(
|
||||
() => projectScopeOptions.find((option) => option.key === projectScopeKey) ?? projectScopeOptions[0] ?? null,
|
||||
[projectScopeKey, projectScopeOptions],
|
||||
);
|
||||
|
||||
const discovered = useMemo(() => {
|
||||
if (!scanResult) {
|
||||
return [];
|
||||
}
|
||||
const registered = new Set(projectScopeOptions.map((option) => option.key));
|
||||
return scanResult.projects.filter((project) => !registered.has(project.key));
|
||||
}, [projectScopeOptions, scanResult]);
|
||||
|
||||
const navigate = (mode: ProjectScopeMode, key: string) => {
|
||||
const href = buildHref(pathname, mode, key);
|
||||
router.push(href);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const setMode = (mode: ProjectScopeMode) => {
|
||||
navigate(mode, projectScopeKey);
|
||||
};
|
||||
|
||||
const setProjectKey = (key: string) => {
|
||||
navigate(projectScopeMode, key);
|
||||
};
|
||||
|
||||
const addProject = async () => {
|
||||
if (!addPath.trim()) {
|
||||
setStatusMessage('Enter an absolute Windows path (example: C:/Repos/MyProject).');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setStatusMessage(null);
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ path: addPath.trim() }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? 'Failed to add project.');
|
||||
}
|
||||
setAddPath('');
|
||||
setStatusMessage('Project added.');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setStatusMessage(error instanceof Error ? error.message : 'Failed to add project.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeProject = async (path: string, key: string) => {
|
||||
setBusy(true);
|
||||
setStatusMessage(null);
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? 'Failed to remove project.');
|
||||
}
|
||||
if (projectScopeKey === key) {
|
||||
navigate(projectScopeMode, 'local');
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
setStatusMessage('Project removed.');
|
||||
} catch (error) {
|
||||
setStatusMessage(error instanceof Error ? error.message : 'Failed to remove project.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runScan = async () => {
|
||||
setBusy(true);
|
||||
setStatusMessage(null);
|
||||
try {
|
||||
const response = await fetch(`/api/scan?mode=${scanMode}`, { cache: 'no-store' });
|
||||
const payload = (await response.json()) as ScannerPayload & { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? 'Scan failed.');
|
||||
}
|
||||
setScanResult(payload);
|
||||
setStatusMessage(`Scan complete. Found ${payload.projects.length} projects.`);
|
||||
} catch (error) {
|
||||
setStatusMessage(error instanceof Error ? error.message : 'Scan failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const importProject = async (project: ScannerProject) => {
|
||||
setBusy(true);
|
||||
setStatusMessage(null);
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ path: project.displayPath }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? 'Import failed.');
|
||||
}
|
||||
setStatusMessage(`Imported ${project.displayPath}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setStatusMessage(error instanceof Error ? error.message : 'Import failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border-soft bg-surface/65 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Scope</label>
|
||||
<select
|
||||
className="ui-field ui-select rounded-lg px-2 py-1 text-xs"
|
||||
value={projectScopeKey}
|
||||
onChange={(event) => setProjectKey(event.target.value)}
|
||||
>
|
||||
{projectScopeOptions.map((option) => (
|
||||
<option className="ui-option" key={option.key} value={option.key}>
|
||||
{option.source === 'local' ? 'Local workspace' : option.displayPath}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Mode</label>
|
||||
<select
|
||||
className="ui-field ui-select rounded-lg px-2 py-1 text-xs"
|
||||
value={projectScopeMode}
|
||||
onChange={(event) => setMode(event.target.value as ProjectScopeMode)}
|
||||
>
|
||||
<option className="ui-option" value="single">Single project</option>
|
||||
<option className="ui-option" value="aggregate">Aggregate</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="rounded-lg border border-border-soft bg-surface-muted/70 px-2.5 py-1 text-xs text-text-body"
|
||||
>
|
||||
{open ? 'Hide manager' : 'Manage projects'}
|
||||
</button>
|
||||
</div>
|
||||
{selected ? (
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
Active: <span className="font-mono text-text-body">{selected.source === 'local' ? 'local workspace' : selected.displayPath}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{open ? (
|
||||
<div className="mt-3 grid gap-3 border-t border-border-soft pt-3">
|
||||
<div className="rounded-lg border border-border-soft/80 bg-surface/55 p-2.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Add project root</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="ui-field flex-1 rounded-lg px-2 py-1.5 text-xs"
|
||||
placeholder="C:/Repos/MyProject"
|
||||
value={addPath}
|
||||
onChange={(event) => setAddPath(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void addProject()}
|
||||
className="rounded-lg border border-border-soft bg-surface-muted/70 px-2.5 py-1.5 text-xs text-text-body disabled:opacity-60"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-soft/80 bg-surface/55 p-2.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Registered projects</p>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{projectScopeOptions.filter((option) => option.source === 'registry').map((option) => (
|
||||
<div key={option.key} className="flex items-center justify-between gap-2 rounded-md border border-border-soft/80 bg-surface-muted/40 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjectKey(option.key)}
|
||||
className="truncate text-left font-mono text-xs text-text-body hover:text-sky-100"
|
||||
title={option.displayPath}
|
||||
>
|
||||
{option.displayPath}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void removeProject(option.displayPath, option.key)}
|
||||
className="rounded border border-rose-300/30 bg-rose-500/10 px-1.5 py-0.5 text-[11px] text-rose-100 disabled:opacity-60"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{projectScopeOptions.every((option) => option.source !== 'registry') ? (
|
||||
<p className="text-xs text-text-muted">No registered projects yet.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-soft/80 bg-surface/55 p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Scanner</p>
|
||||
<select
|
||||
className="ui-field ui-select rounded-lg px-2 py-1 text-xs"
|
||||
value={scanMode}
|
||||
onChange={(event) => setScanMode(event.target.value as 'default' | 'full-drive')}
|
||||
>
|
||||
<option className="ui-option" value="default">Safe roots</option>
|
||||
<option className="ui-option" value="full-drive">Full drive</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void runScan()}
|
||||
className="rounded-lg border border-border-soft bg-surface-muted/70 px-2.5 py-1 text-xs text-text-body disabled:opacity-60"
|
||||
>
|
||||
Run scan
|
||||
</button>
|
||||
</div>
|
||||
{scanResult ? (
|
||||
<p className="mt-2 text-[11px] text-text-muted">
|
||||
scanned {scanResult.stats.scannedDirectories}, ignored {scanResult.stats.ignoredDirectories}, skipped {scanResult.stats.skippedDirectories} ({scanResult.stats.elapsedMs}ms)
|
||||
</p>
|
||||
) : null}
|
||||
{discovered.length > 0 ? (
|
||||
<div className="mt-2 max-h-40 space-y-1.5 overflow-y-auto pr-1">
|
||||
{discovered.map((project) => (
|
||||
<div key={project.key} className="flex items-center justify-between gap-2 rounded-md border border-border-soft/80 bg-surface-muted/40 px-2 py-1.5">
|
||||
<span className="truncate font-mono text-xs text-text-body" title={project.displayPath}>
|
||||
{project.displayPath}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void importProject(project)}
|
||||
className="rounded border border-sky-300/30 bg-sky-500/10 px-1.5 py-0.5 text-[11px] text-sky-100 disabled:opacity-60"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : scanResult ? (
|
||||
<p className="mt-2 text-xs text-text-muted">No new projects to import.</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{statusMessage ? <p className="text-xs text-text-muted">{statusMessage}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{searchParams.get('project') && projectScopeKey === 'local' ? (
|
||||
<p className="mt-2 text-[11px] text-amber-200/90">Unknown project key in URL; fell back to local workspace.</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue