feat(graph): Implement Graph View with Dagre Layout and Epic Scope (bb-18e)

This commit is contained in:
zenchantlive 2026-02-12 23:36:41 -08:00
parent 7ab23448f0
commit 8490cb1d8c
33 changed files with 4936 additions and 38 deletions

38
src/app/graph/page.tsx Normal file
View file

@ -0,0 +1,38 @@
import { DependencyGraphPage } from '../../components/graph/dependency-graph-page';
export const dynamic = 'force-dynamic';
import { readIssuesForScope } from '../../lib/aggregate-read';
import { resolveProjectScope } from '../../lib/project-scope';
import { listProjects } from '../../lib/registry';
interface GraphPageProps {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}
export default async function GraphPage({ searchParams }: GraphPageProps) {
const params = (await searchParams) ?? {};
const requestedProjectKey = typeof params.project === 'string' ? params.project : null;
const requestedMode = typeof params.mode === 'string' ? params.mode : null;
const registryProjects = await listProjects();
const scope = resolveProjectScope({
currentProjectRoot: process.cwd(),
registryProjects,
requestedProjectKey,
requestedMode,
});
const issues = await readIssuesForScope({
mode: scope.mode,
selected: scope.selected,
scopeOptions: scope.options,
});
return (
<DependencyGraphPage
issues={issues}
projectRoot={scope.selected.root}
projectScopeKey={scope.selected.key}
projectScopeOptions={scope.options}
projectScopeMode={scope.mode}
/>
);
}

View 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 &bull; {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>
);
}

View 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">
&larr; 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 &bull; <span className="uppercase">{selectedEpic?.status ?? 'unknown'}</span>
</p>
</section>
{/* Task cards */}
<section className="rounded-2xl bg-white/[0.02] p-5 ring-1 ring-white/5">
<h3 className="mb-4 text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">2) Pick Task</h3>
<div className="max-h-[30vh] overflow-y-auto overscroll-contain custom-scrollbar">
<TaskCardGrid
tasks={sortedEpicTasks}
selectedId={selectedId}
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>
);
}

View 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>
);
}

View 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">
&bull; {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>
);
}

View 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 &bull;{' '}
<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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

69
src/lib/aggregate-read.ts Normal file
View file

@ -0,0 +1,69 @@
import type { BeadDependency, BeadIssueWithProject } from './types';
import type { ProjectScopeOption } from './project-scope';
import { readIssuesFromDisk } from './read-issues';
function scopeIssueId(projectKey: string, issueId: string): string {
return `${projectKey}::${issueId}`;
}
function remapDependencies(
dependencies: BeadDependency[],
scopedIssueByOriginalId: Map<string, string>,
): BeadDependency[] {
return dependencies.map((dependency) => ({
...dependency,
target: scopedIssueByOriginalId.get(dependency.target) ?? dependency.target,
}));
}
function scopeIssuesForProject(
project: ProjectScopeOption,
issues: BeadIssueWithProject[],
): BeadIssueWithProject[] {
const scopedIssueByOriginalId = new Map<string, string>();
for (const issue of issues) {
scopedIssueByOriginalId.set(issue.id, scopeIssueId(project.key, issue.id));
}
return issues.map((issue) => {
const scopedId = scopedIssueByOriginalId.get(issue.id) ?? scopeIssueId(project.key, issue.id);
return {
...issue,
id: scopedId,
dependencies: remapDependencies(issue.dependencies, scopedIssueByOriginalId),
metadata: {
...issue.metadata,
original_id: issue.id,
project_key: project.key,
},
project: {
...issue.project,
key: project.key,
},
};
});
}
export async function readIssuesForScope(options: {
mode: 'single' | 'aggregate';
selected: ProjectScopeOption;
scopeOptions: ProjectScopeOption[];
}): Promise<BeadIssueWithProject[]> {
if (options.mode === 'single') {
return readIssuesFromDisk({
projectRoot: options.selected.root,
projectSource: options.selected.source,
});
}
const result = await Promise.all(
options.scopeOptions.map(async (project) => {
const issues = await readIssuesFromDisk({
projectRoot: project.root,
projectSource: project.source,
});
return scopeIssuesForProject(project, issues);
}),
);
return result.flat();
}

489
src/lib/graph-view.ts Normal file
View file

@ -0,0 +1,489 @@
import dagre from 'dagre';
import type { GraphEdge, GraphModel, GraphNode } from './graph';
export type GraphHopDepth = 1 | 2 | 'full';
export interface GraphViewOptions {
focusId: string | null;
depth: GraphHopDepth;
hideClosed: boolean;
}
export interface PositionedGraphNode extends GraphNode {
position: { x: number; y: number };
}
export interface GraphViewModel {
nodes: PositionedGraphNode[];
edges: GraphEdge[];
}
export interface PathWorkspace {
focus: GraphNode | null;
blockers: GraphNode[][];
dependents: GraphNode[][];
}
const NODE_WIDTH = 340;
const NODE_HEIGHT = 132;
function sortEdges(a: GraphEdge, b: GraphEdge): number {
if (a.source !== b.source) {
return a.source.localeCompare(b.source);
}
if (a.type !== b.type) {
return a.type.localeCompare(b.type);
}
return a.target.localeCompare(b.target);
}
function sortNodes(a: GraphNode, b: GraphNode): number {
return a.id.localeCompare(b.id);
}
function collectIdsWithDepth(model: GraphModel, focusId: string, depth: Exclude<GraphHopDepth, 'full'>): Set<string> {
const visited = new Set<string>([focusId]);
let frontier = new Set<string>([focusId]);
for (let step = 0; step < depth; step += 1) {
const next = new Set<string>();
for (const nodeId of frontier) {
const adjacency = model.adjacency[nodeId];
if (!adjacency) {
continue;
}
for (const edge of adjacency.outgoing) {
if (!visited.has(edge.target)) {
visited.add(edge.target);
next.add(edge.target);
}
}
for (const edge of adjacency.incoming) {
if (!visited.has(edge.source)) {
visited.add(edge.source);
next.add(edge.source);
}
}
}
frontier = next;
if (frontier.size === 0) {
break;
}
}
return visited;
}
function applyFocusWorkspaceLayout(nodes: GraphNode[], edges: GraphEdge[], focusId: string): PositionedGraphNode[] {
const incomingDepth = new Map<string, number>([[focusId, 0]]);
const outgoingDepth = new Map<string, number>([[focusId, 0]]);
let incomingFrontier = new Set<string>([focusId]);
let outgoingFrontier = new Set<string>([focusId]);
let incomingStep = 0;
let outgoingStep = 0;
while (incomingFrontier.size > 0) {
incomingStep += 1;
const next = new Set<string>();
for (const nodeId of incomingFrontier) {
for (const edge of edges) {
if (edge.target !== nodeId) {
continue;
}
if (!incomingDepth.has(edge.source)) {
incomingDepth.set(edge.source, incomingStep);
next.add(edge.source);
}
}
}
incomingFrontier = next;
}
while (outgoingFrontier.size > 0) {
outgoingStep += 1;
const next = new Set<string>();
for (const nodeId of outgoingFrontier) {
for (const edge of edges) {
if (edge.source !== nodeId) {
continue;
}
if (!outgoingDepth.has(edge.target)) {
outgoingDepth.set(edge.target, outgoingStep);
next.add(edge.target);
}
}
}
outgoingFrontier = next;
}
const columns = new Map<number, GraphNode[]>();
for (const node of nodes) {
let column = 0;
if (node.id !== focusId) {
const inDepth = incomingDepth.get(node.id);
const outDepth = outgoingDepth.get(node.id);
if (inDepth && outDepth) {
column = inDepth <= outDepth ? -inDepth : outDepth;
} else if (inDepth) {
column = -inDepth;
} else if (outDepth) {
column = outDepth;
}
}
const bucket = columns.get(column) ?? [];
bucket.push(node);
columns.set(column, bucket);
}
const columnKeys = [...columns.keys()].sort((a, b) => a - b);
const positioned: PositionedGraphNode[] = [];
for (const columnKey of columnKeys) {
const columnNodes = (columns.get(columnKey) ?? []).sort((a, b) => a.id.localeCompare(b.id));
columnNodes.forEach((node, rowIndex) => {
positioned.push({
...node,
position: {
x: (columnKey + 3) * (NODE_WIDTH + 60),
y: rowIndex * (NODE_HEIGHT + 26),
},
});
});
}
return positioned.sort((a, b) => {
if (a.id === focusId) {
return -1;
}
if (b.id === focusId) {
return 1;
}
return a.id.localeCompare(b.id);
});
}
function applyLayout(nodes: GraphNode[], edges: GraphEdge[], focusId: string | null): PositionedGraphNode[] {
if (focusId) {
return applyFocusWorkspaceLayout(nodes, edges, focusId);
}
if (edges.length === 0) {
const columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length)));
return nodes.map((node, index) => {
const col = index % columns;
const row = Math.floor(index / columns);
return {
...node,
position: {
x: col * (NODE_WIDTH + 36),
y: row * (NODE_HEIGHT + 28),
},
};
});
}
const graph = new dagre.graphlib.Graph();
graph.setDefaultEdgeLabel(() => ({}));
graph.setGraph({
rankdir: 'LR',
ranksep: 110,
nodesep: 36,
});
for (const node of nodes) {
graph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
}
for (const edge of edges) {
graph.setEdge(edge.source, edge.target);
}
dagre.layout(graph);
const positioned = nodes
.map((node) => {
const point = graph.node(node.id);
return {
...node,
position: {
x: Math.round((point?.x ?? 0) - NODE_WIDTH / 2),
y: Math.round((point?.y ?? 0) - NODE_HEIGHT / 2),
},
};
})
.sort((a, b) => {
if (focusId && a.id === focusId) {
return -1;
}
if (focusId && b.id === focusId) {
return 1;
}
return a.id.localeCompare(b.id);
});
return positioned;
}
export function buildGraphViewModel(model: GraphModel, options: GraphViewOptions): GraphViewModel {
const nodeById = new Map(model.nodes.map((node) => [node.id, node]));
const baseVisibleIds = options.focusId
? options.depth === 'full'
? new Set(model.nodes.map((node) => node.id))
: collectIdsWithDepth(model, options.focusId, options.depth)
: new Set(model.nodes.map((node) => node.id));
const filteredIds = new Set(
[...baseVisibleIds].filter((id) => {
const node = nodeById.get(id);
if (!node) {
return false;
}
if (!options.hideClosed) {
return true;
}
if (id === options.focusId) {
return true;
}
return node.status !== 'closed';
}),
);
const nodes = model.nodes.filter((node) => filteredIds.has(node.id)).sort(sortNodes);
const edges = model.edges
.filter((edge) => filteredIds.has(edge.source) && filteredIds.has(edge.target))
.sort(sortEdges);
return {
nodes: applyLayout(nodes, edges, options.focusId),
edges,
};
}
function includeByClosedFilter(node: GraphNode, hideClosed: boolean, forceInclude: boolean): boolean {
if (forceInclude) {
return true;
}
if (!hideClosed) {
return true;
}
return node.status !== 'closed';
}
export function buildPathWorkspace(model: GraphModel, options: GraphViewOptions): PathWorkspace {
const nodeById = new Map(model.nodes.map((node) => [node.id, node]));
const focusId = options.focusId;
const focusNode = focusId ? nodeById.get(focusId) ?? null : null;
if (!focusNode || !focusId) {
return { focus: null, blockers: [], dependents: [] };
}
const maxDepth = options.depth === 'full' ? Number.POSITIVE_INFINITY : options.depth;
const blockers: GraphNode[][] = [];
const dependents: GraphNode[][] = [];
const blockerSeen = new Set<string>([focusId]);
const dependentSeen = new Set<string>([focusId]);
let blockerFrontier = new Set<string>([focusId]);
let dependentFrontier = new Set<string>([focusId]);
for (let depth = 1; depth <= maxDepth; depth += 1) {
const nextBlockerFrontier = new Set<string>();
const nextDependentFrontier = new Set<string>();
const blockerLevel: GraphNode[] = [];
const dependentLevel: GraphNode[] = [];
for (const nodeId of blockerFrontier) {
const adjacency = model.adjacency[nodeId];
if (!adjacency) {
continue;
}
for (const edge of adjacency.incoming) {
if (blockerSeen.has(edge.source)) {
continue;
}
blockerSeen.add(edge.source);
nextBlockerFrontier.add(edge.source);
const node = nodeById.get(edge.source);
if (node && includeByClosedFilter(node, options.hideClosed, false)) {
blockerLevel.push(node);
}
}
}
for (const nodeId of dependentFrontier) {
const adjacency = model.adjacency[nodeId];
if (!adjacency) {
continue;
}
for (const edge of adjacency.outgoing) {
if (dependentSeen.has(edge.target)) {
continue;
}
dependentSeen.add(edge.target);
nextDependentFrontier.add(edge.target);
const node = nodeById.get(edge.target);
if (node && includeByClosedFilter(node, options.hideClosed, false)) {
dependentLevel.push(node);
}
}
}
blockerLevel.sort(sortNodes);
dependentLevel.sort(sortNodes);
if (blockerLevel.length > 0) {
blockers.push(blockerLevel);
}
if (dependentLevel.length > 0) {
dependents.push(dependentLevel);
}
blockerFrontier = nextBlockerFrontier;
dependentFrontier = nextDependentFrontier;
if (blockerFrontier.size === 0 && dependentFrontier.size === 0) {
break;
}
}
return { focus: focusNode, blockers, dependents };
}
export interface BlockedChainAnalysis {
blockerNodeIds: string[];
openBlockerCount: number;
inProgressBlockerCount: number;
firstActionableBlockerId: string | null;
chainEdgeIds: string[];
}
export function analyzeBlockedChain(model: GraphModel, options: { focusId: string }): BlockedChainAnalysis {
const focusId = options.focusId;
const visited = new Set<string>([focusId]);
let queue = [focusId];
const chainEdgeIds: string[] = [];
const blockerNodeIds: string[] = [];
while (queue.length > 0) {
const nodeId = queue.shift()!;
const adjacency = model.adjacency[nodeId];
if (!adjacency) continue;
for (const edge of adjacency.incoming) {
if (edge.type !== 'blocks') continue;
chainEdgeIds.push(`${edge.source}:${edge.type}:${edge.target}`);
if (!visited.has(edge.source)) {
visited.add(edge.source);
queue.push(edge.source);
blockerNodeIds.push(edge.source);
}
}
}
const blockers = blockerNodeIds.map(id => model.nodes.find(n => n.id === id)).filter(Boolean) as GraphNode[];
const openBlockers = blockers.filter((b) => b.status !== 'closed');
const inProgress = openBlockers.filter((b) => b.status === 'in_progress');
const openCount = openBlockers.filter(b => b.status === 'open' || b.status === 'blocked').length;
const firstActionable = openBlockers.find((b) => {
const adj = model.adjacency[b.id];
if (!adj) return true;
return !adj.incoming.some(e => e.type === 'blocks' && model.nodes.find(n => n.id === e.source)?.status !== 'closed');
});
return {
blockerNodeIds: blockerNodeIds.sort(),
openBlockerCount: openCount,
inProgressBlockerCount: inProgress.length,
firstActionableBlockerId: firstActionable?.id ?? null,
chainEdgeIds: chainEdgeIds.sort(),
};
}
export interface CycleAnomaly {
cycles: string[][];
cycleNodeIds: string[];
cycleEdgeIds: string[];
}
export function detectDependencyCycles(model: GraphModel): CycleAnomaly {
const cycleNodeIdsSet = new Set<string>();
const cycleEdgeIdsSet = new Set<string>();
const cycleKeys = new Set<string>();
const cycles: string[][] = [];
const relevantEdges = model.edges.filter((e) => e.type === 'blocks');
const adj = new Map<string, string[]>();
for (const node of model.nodes) {
adj.set(node.id, []);
}
for (const edge of relevantEdges) {
const list = adj.get(edge.source) ?? [];
list.push(edge.target);
adj.set(edge.source, list);
}
for (const [nodeId, neighbors] of adj.entries()) {
adj.set(
nodeId,
[...neighbors].sort((a, b) => a.localeCompare(b)),
);
}
const visited = new Set<string>();
const recStack = new Set<string>();
const path: string[] = [];
function walk(nodeId: string): void {
visited.add(nodeId);
recStack.add(nodeId);
path.push(nodeId);
const neighbors = adj.get(nodeId) ?? [];
for (const nextId of neighbors) {
if (!visited.has(nextId)) {
walk(nextId);
} else if (recStack.has(nextId)) {
const cycleStartIndex = path.indexOf(nextId);
if (cycleStartIndex >= 0) {
const cycleNodes = path.slice(cycleStartIndex);
const canonical = [...cycleNodes].sort((a, b) => a.localeCompare(b));
const cycleKey = canonical.join('|');
if (!cycleKeys.has(cycleKey)) {
cycleKeys.add(cycleKey);
cycles.push(canonical);
}
canonical.forEach((id) => cycleNodeIdsSet.add(id));
for (let i = 0; i < cycleNodes.length; i += 1) {
const s = cycleNodes[i];
const t = cycleNodes[(i + 1) % cycleNodes.length];
cycleEdgeIdsSet.add(`${s}:blocks:${t}`);
}
}
}
}
recStack.delete(nodeId);
path.pop();
}
for (const node of model.nodes) {
if (!visited.has(node.id)) {
walk(node.id);
}
}
cycles.sort((a, b) => a.join('|').localeCompare(b.join('|')));
return {
cycles,
cycleNodeIds: [...cycleNodeIdsSet].sort(),
cycleEdgeIds: [...cycleEdgeIdsSet].sort(),
};
}

137
src/lib/graph.ts Normal file
View file

@ -0,0 +1,137 @@
import type { BeadDependencyType, BeadIssue } from './types';
type SupportedGraphEdgeType = Extract<
BeadDependencyType,
'blocks' | 'parent' | 'relates_to' | 'duplicates' | 'supersedes'
>;
const SUPPORTED_EDGE_TYPES = new Set<BeadDependencyType>([
'blocks',
'parent',
'relates_to',
'duplicates',
'supersedes',
]);
export interface GraphNode {
id: string;
title: string;
status: BeadIssue['status'];
priority: number;
issueType: string;
assignee: string | null;
updatedAt: string;
}
export interface GraphEdge {
source: string;
target: string;
type: SupportedGraphEdgeType;
}
export interface GraphAdjacencyEntry {
incoming: GraphEdge[];
outgoing: GraphEdge[];
}
export interface GraphModelDiagnostics {
missingTargets: number;
droppedDuplicates: number;
unsupportedTypes: number;
}
export interface GraphModel {
nodes: GraphNode[];
edges: GraphEdge[];
adjacency: Record<string, GraphAdjacencyEntry>;
diagnostics: GraphModelDiagnostics;
projectKey: string | null;
}
export interface BuildGraphModelOptions {
projectKey?: string;
}
function edgeSort(a: GraphEdge, b: GraphEdge): number {
if (a.source !== b.source) {
return a.source.localeCompare(b.source);
}
if (a.type !== b.type) {
return a.type.localeCompare(b.type);
}
return a.target.localeCompare(b.target);
}
function isSupportedEdgeType(type: BeadDependencyType): type is SupportedGraphEdgeType {
return SUPPORTED_EDGE_TYPES.has(type);
}
export function buildGraphModel(issues: BeadIssue[], options: BuildGraphModelOptions = {}): GraphModel {
const nodes = issues
.map((issue) => ({
id: issue.id,
title: issue.title,
status: issue.status,
priority: issue.priority,
issueType: issue.issue_type,
assignee: issue.assignee,
updatedAt: issue.updated_at,
}))
.sort((a, b) => a.id.localeCompare(b.id));
const nodeIds = new Set(nodes.map((node) => node.id));
const edgeKeys = new Set<string>();
const edges: GraphEdge[] = [];
const diagnostics: GraphModelDiagnostics = {
missingTargets: 0,
droppedDuplicates: 0,
unsupportedTypes: 0,
};
for (const issue of issues) {
for (const dependency of issue.dependencies) {
if (!isSupportedEdgeType(dependency.type)) {
diagnostics.unsupportedTypes += 1;
continue;
}
if (!nodeIds.has(dependency.target)) {
diagnostics.missingTargets += 1;
continue;
}
const edgeKey = `${issue.id}::${dependency.type}::${dependency.target}`;
if (edgeKeys.has(edgeKey)) {
diagnostics.droppedDuplicates += 1;
continue;
}
edgeKeys.add(edgeKey);
edges.push({
source: issue.id,
target: dependency.target,
type: dependency.type,
});
}
}
edges.sort(edgeSort);
const adjacency: Record<string, GraphAdjacencyEntry> = {};
for (const node of nodes) {
adjacency[node.id] = { incoming: [], outgoing: [] };
}
for (const edge of edges) {
adjacency[edge.source].outgoing.push(edge);
adjacency[edge.target].incoming.push(edge);
}
return {
nodes,
edges,
adjacency,
diagnostics,
projectKey: options.projectKey ?? null,
};
}

168
src/lib/issue-editor.ts Normal file
View file

@ -0,0 +1,168 @@
import type { MutationStatus, UpdateMutationPayload } from './mutations';
import type { BeadIssue } from './types';
export interface EditableIssueDraft {
title: string;
description: string;
status: MutationStatus;
priority: number;
issueType: string;
assignee: string;
owner: string;
labelsInput: string;
}
export type EditableIssueFieldErrors = Partial<Record<keyof EditableIssueDraft, string>>;
export type EditState = 'pristine' | 'dirty' | 'saving' | 'error';
export function parseLabelsInput(labelsInput: string): string[] {
const seen = new Set<string>();
const labels: string[] = [];
for (const rawPart of labelsInput.split(',')) {
const part = rawPart.trim();
if (!part || seen.has(part)) {
continue;
}
seen.add(part);
labels.push(part);
}
return labels;
}
export function buildEditableIssueDraft(issue: BeadIssue): EditableIssueDraft {
const editableStatus: MutationStatus =
issue.status === 'open' ||
issue.status === 'in_progress' ||
issue.status === 'blocked' ||
issue.status === 'deferred' ||
issue.status === 'closed'
? issue.status
: 'open';
return {
title: issue.title,
description: issue.description ?? '',
status: editableStatus,
priority: issue.priority,
issueType: issue.issue_type,
assignee: issue.assignee ?? '',
owner: issue.owner ?? '',
labelsInput: issue.labels.map((label) => label.trim()).filter(Boolean).join(', '),
};
}
export function validateEditableIssueDraft(draft: EditableIssueDraft): { ok: true; errors: {} } | { ok: false; errors: EditableIssueFieldErrors } {
const errors: EditableIssueFieldErrors = {};
if (!draft.title.trim()) {
errors.title = 'Title is required.';
}
if (!Number.isInteger(draft.priority) || draft.priority < 0 || draft.priority > 4) {
errors.priority = 'Priority must be between 0 and 4.';
}
if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(draft.status)) {
errors.status = 'Status must be open, in progress, blocked, deferred, or closed.';
}
if (!draft.issueType.trim()) {
errors.issueType = 'Issue type is required.';
}
const parts = draft.labelsInput.split(',').map((part) => part.trim());
if (parts.some((part) => part.length === 0) && draft.labelsInput.trim().length > 0) {
errors.labelsInput = 'Labels must be comma-separated non-empty values.';
}
if (Object.keys(errors).length > 0) {
return { ok: false, errors };
}
return { ok: true, errors: {} };
}
function normalizeNullable(value: string): string | undefined {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function labelsChanged(current: string[], next: string[]): boolean {
if (current.length !== next.length) {
return true;
}
for (let i = 0; i < current.length; i += 1) {
if (current[i] !== next[i]) {
return true;
}
}
return false;
}
export function buildIssueUpdatePayload(
issue: BeadIssue,
draft: EditableIssueDraft,
projectRoot: string,
): UpdateMutationPayload | null {
const nextTitle = draft.title.trim();
const nextDescription = draft.description.trim();
const nextAssignee = normalizeNullable(draft.assignee);
const nextIssueType = draft.issueType.trim();
const nextLabels = parseLabelsInput(draft.labelsInput);
const payload: UpdateMutationPayload = {
projectRoot,
id: issue.id,
};
if (nextTitle !== issue.title) {
payload.title = nextTitle;
}
if (nextDescription !== (issue.description ?? '')) {
payload.description = nextDescription;
}
if (draft.priority !== issue.priority) {
payload.priority = draft.priority;
}
if (draft.status !== issue.status) {
payload.status = draft.status;
}
if (nextIssueType !== issue.issue_type) {
payload.issueType = nextIssueType;
}
if (nextAssignee !== (issue.assignee ?? undefined)) {
payload.assignee = nextAssignee;
}
if (labelsChanged(issue.labels, nextLabels)) {
payload.labels = nextLabels;
}
if (
payload.title === undefined &&
payload.description === undefined &&
payload.status === undefined &&
payload.priority === undefined &&
payload.issueType === undefined &&
payload.assignee === undefined &&
payload.labels === undefined
) {
return null;
}
return payload;
}
export function classifyEditState(input: { dirty: boolean; saving: boolean; error: string | null }): EditState {
if (input.saving) {
return 'saving';
}
if (input.error) {
return 'error';
}
if (input.dirty) {
return 'dirty';
}
return 'pristine';
}

View file

@ -1,6 +1,6 @@
import type { BeadIssue } from './types';
export const KANBAN_STATUSES = ['open', 'in_progress', 'blocked', 'deferred', 'closed'] as const;
export const KANBAN_STATUSES = ['ready', 'in_progress', 'blocked', 'closed'] as const;
export type KanbanStatus = (typeof KANBAN_STATUSES)[number];
@ -15,15 +15,29 @@ export interface KanbanFilterOptions {
export interface KanbanStats {
total: number;
open: number;
ready: number;
active: number;
blocked: number;
done: number;
p0: number;
}
function isKanbanStatus(status: string): status is KanbanStatus {
return KANBAN_STATUSES.includes(status as KanbanStatus);
export type BoardMutationStatus = 'open' | 'in_progress' | 'blocked' | 'closed';
export interface BlockedTreeNode {
id: string;
title: string;
level: number;
}
export interface ExecutionChecklistItem {
key: 'owner_assigned' | 'no_open_blockers' | 'quality_signal' | 'execution_compatible';
label: string;
passed: boolean;
}
function isReviewStatus(status: string): boolean {
return status.toLowerCase().includes('review');
}
function issueSort(a: BeadIssue, b: BeadIssue): number {
@ -35,6 +49,65 @@ function issueSort(a: BeadIssue, b: BeadIssue): number {
return b.updated_at.localeCompare(a.updated_at);
}
function hasOpenBlockers(issues: BeadIssue[], targetId: string): boolean {
return issues.some(
(issue) =>
issue.status !== 'closed' &&
issue.dependencies.some((dep) => dep.type === 'blocks' && dep.target === targetId),
);
}
function hasQualitySignal(issue: BeadIssue): boolean {
const description = issue.description?.trim() ?? '';
if (description.length > 0) {
return true;
}
if (issue.labels.some((label) => label.toLowerCase().includes('accept'))) {
return true;
}
const acceptance = issue.metadata.acceptance;
if (typeof acceptance === 'string') {
return acceptance.trim().length > 0;
}
if (Array.isArray(acceptance)) {
return acceptance.length > 0;
}
return false;
}
function deriveBlockedIds(issues: BeadIssue[]): Set<string> {
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
const blockedIds = new Set<string>();
for (const issue of issues) {
for (const dep of issue.dependencies) {
if (dep.type !== 'blocks') continue;
const blocker = issueById.get(issue.id);
if (!blocker) continue;
if (blocker.status === 'closed') continue;
blockedIds.add(dep.target);
}
}
return blockedIds;
}
function laneForIssue(issue: BeadIssue, blockedIds: Set<string>): KanbanStatus {
if (issue.status === 'closed') {
return 'closed';
}
if (issue.status === 'blocked' || blockedIds.has(issue.id)) {
return 'blocked';
}
if (issue.status === 'in_progress' || isReviewStatus(issue.status)) {
return 'in_progress';
}
return 'ready';
}
export function filterKanbanIssues(issues: BeadIssue[], filters: KanbanFilterOptions): BeadIssue[] {
const query = (filters.query ?? '').trim().toLowerCase();
const type = (filters.type ?? '').trim().toLowerCase();
@ -67,17 +140,19 @@ export function filterKanbanIssues(issues: BeadIssue[], filters: KanbanFilterOpt
export function buildKanbanColumns(issues: BeadIssue[]): KanbanColumns {
const columns = {
open: [],
ready: [],
in_progress: [],
blocked: [],
deferred: [],
closed: [],
} as KanbanColumns;
const blockedIds = deriveBlockedIds(issues);
for (const issue of issues) {
if (isKanbanStatus(issue.status)) {
columns[issue.status].push(issue);
const lane = laneForIssue(issue, blockedIds);
if (lane === 'ready' && issue.issue_type === 'epic') {
continue;
}
columns[lane].push(issue);
}
for (const status of KANBAN_STATUSES) {
@ -88,12 +163,176 @@ export function buildKanbanColumns(issues: BeadIssue[]): KanbanColumns {
}
export function buildKanbanStats(issues: BeadIssue[]): KanbanStats {
const columns = buildKanbanColumns(issues);
return {
total: issues.length,
open: issues.filter((x) => x.status === 'open').length,
active: issues.filter((x) => x.status === 'in_progress').length,
blocked: issues.filter((x) => x.status === 'blocked').length,
done: issues.filter((x) => x.status === 'closed').length,
ready: columns.ready.length,
active: columns.in_progress.length,
blocked: columns.blocked.length,
done: columns.closed.length,
p0: issues.filter((x) => x.priority === 0).length,
};
}
export function laneToMutationStatus(status: KanbanStatus): BoardMutationStatus {
switch (status) {
case 'ready':
return 'open';
case 'in_progress':
return 'in_progress';
case 'blocked':
return 'blocked';
case 'closed':
return 'closed';
default:
return 'open';
}
}
export function buildBlockedByTree(
issues: BeadIssue[],
focusId: string | null,
options: { maxNodes?: number } = {},
): { total: number; nodes: BlockedTreeNode[] } {
if (!focusId) {
return { total: 0, nodes: [] };
}
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
const incomingByTarget = new Map<string, string[]>();
for (const issue of issues) {
for (const dep of issue.dependencies) {
if (dep.type !== 'blocks') continue;
const list = incomingByTarget.get(dep.target) ?? [];
list.push(issue.id);
incomingByTarget.set(dep.target, list);
}
}
for (const [targetId, blockerIds] of incomingByTarget.entries()) {
incomingByTarget.set(
targetId,
[...new Set(blockerIds)].sort((a, b) => a.localeCompare(b)),
);
}
const maxNodes = Math.max(1, options.maxNodes ?? 12);
const visited = new Set<string>([focusId]);
const queued = new Set<string>();
const queue: Array<{ id: string; level: number }> = [{ id: focusId, level: 0 }];
const nodes: BlockedTreeNode[] = [];
let total = 0;
while (queue.length > 0) {
const current = queue.shift() as { id: string; level: number };
const blockers = incomingByTarget.get(current.id) ?? [];
for (const blockerId of blockers) {
if (visited.has(blockerId) || queued.has(blockerId)) continue;
queued.add(blockerId);
total += 1;
const blocker = issueById.get(blockerId);
if (nodes.length < maxNodes) {
nodes.push({
id: blockerId,
title: blocker?.title ?? blockerId,
level: current.level + 1,
});
}
queue.push({ id: blockerId, level: current.level + 1 });
}
visited.add(current.id);
}
return { total, nodes };
}
export function findIssueLane(columns: KanbanColumns, issueId: string): KanbanStatus | null {
for (const status of KANBAN_STATUSES) {
if (columns[status].some((issue) => issue.id === issueId)) {
return status;
}
}
return null;
}
export function buildUnblocksCountByIssue(issues: BeadIssue[]): Map<string, number> {
const unblocksByIssue = new Map<string, number>();
for (const issue of issues) {
const targets = new Set(
issue.dependencies.filter((dep) => dep.type === 'blocks').map((dep) => dep.target),
);
unblocksByIssue.set(issue.id, targets.size);
}
return unblocksByIssue;
}
export function pickNextActionableIssue(columns: KanbanColumns, issues: BeadIssue[]): BeadIssue | null {
if (columns.ready.length === 0) {
return null;
}
const unblocksByIssue = buildUnblocksCountByIssue(issues);
const ranked = [...columns.ready].sort((a, b) => {
const priorityDiff = a.priority - b.priority;
if (priorityDiff !== 0) {
return priorityDiff;
}
const unblocksDiff = (unblocksByIssue.get(b.id) ?? 0) - (unblocksByIssue.get(a.id) ?? 0);
if (unblocksDiff !== 0) {
return unblocksDiff;
}
const updatedDiff = b.updated_at.localeCompare(a.updated_at);
if (updatedDiff !== 0) {
return updatedDiff;
}
return a.id.localeCompare(b.id);
});
return ranked[0] ?? null;
}
export function formatUpdatedRecency(updatedAt: string | null | undefined, nowMs = Date.now()): string {
if (!updatedAt) {
return 'updated unknown';
}
const parsed = Date.parse(updatedAt);
if (Number.isNaN(parsed)) {
return 'updated unknown';
}
const elapsedSeconds = Math.max(0, Math.floor((nowMs - parsed) / 1000));
if (elapsedSeconds < 60) {
return 'updated now';
}
if (elapsedSeconds < 3600) {
return `updated ${Math.floor(elapsedSeconds / 60)}m`;
}
if (elapsedSeconds < 86400) {
return `updated ${Math.floor(elapsedSeconds / 3600)}h`;
}
if (elapsedSeconds < 604800) {
return `updated ${Math.floor(elapsedSeconds / 86400)}d`;
}
return `updated ${Math.floor(elapsedSeconds / 604800)}w`;
}
export function buildExecutionChecklist(issue: BeadIssue, issues: BeadIssue[]): ExecutionChecklistItem[] {
const columns = buildKanbanColumns(issues);
const lane = findIssueLane(columns, issue.id);
const openBlockers = hasOpenBlockers(issues, issue.id);
return [
{ key: 'owner_assigned', label: 'Owner assigned', passed: Boolean(issue.owner?.trim()) },
{ key: 'no_open_blockers', label: 'Not blocked by open blockers', passed: !openBlockers },
{ key: 'quality_signal', label: 'Has acceptance or description signal', passed: hasQualitySignal(issue) },
{
key: 'execution_compatible',
label: 'Execution-compatible status (ready or in progress)',
passed: lane === 'ready' || lane === 'in_progress',
},
];
}

View file

@ -23,6 +23,7 @@ export interface UpdateMutationPayload extends MutationBasePayload {
description?: string;
status?: MutationStatus;
priority?: number;
issueType?: string;
assignee?: string;
labels?: string[];
}
@ -162,11 +163,20 @@ export function validateMutationPayload(operation: MutationOperation, payload: u
description: asOptionalString(data.description),
status: asOptionalStatus(data.status),
priority: asOptionalPriority(data.priority),
issueType: asOptionalString(data.issueType),
assignee: asOptionalString(data.assignee),
labels: asOptionalLabels(data.labels),
};
if (!mapped.title && !mapped.description && !mapped.status && mapped.priority === undefined && !mapped.assignee && !mapped.labels) {
if (
!mapped.title &&
!mapped.description &&
!mapped.status &&
mapped.priority === undefined &&
!mapped.issueType &&
!mapped.assignee &&
!mapped.labels
) {
throw new MutationValidationError('At least one update field is required.');
}
@ -232,6 +242,7 @@ export function buildBdMutationArgs(operation: MutationOperation, payload: Mutat
if (data.priority !== undefined) {
args.push('-p', String(data.priority));
}
pushOptionalArg(args, '-t', data.issueType);
pushOptionalArg(args, '-a', data.assignee);
pushOptionalLabels(args, data.labels);
args.push('--json');

View file

@ -15,14 +15,21 @@ function normalizeDependencies(value: unknown): BeadDependency[] {
return null;
}
const dep = item as { type?: unknown; target?: unknown };
if (typeof dep.type !== 'string' || typeof dep.target !== 'string') {
const dep = item as { type?: unknown; target?: unknown; depends_on_id?: unknown };
if (typeof dep.type !== 'string') {
return null;
}
const target = typeof dep.target === 'string' ? dep.target : typeof dep.depends_on_id === 'string' ? dep.depends_on_id : null;
if (!target) {
return null;
}
const normalizedType = dep.type === 'parent-child' ? 'parent' : dep.type;
return {
type: dep.type as BeadDependency['type'],
target: dep.target,
type: normalizedType as BeadDependency['type'],
target,
};
})
.filter((dep): dep is BeadDependency => dep !== null);

104
src/lib/project-scope.ts Normal file
View file

@ -0,0 +1,104 @@
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
export interface ProjectScopeRegistryEntry {
path: string;
}
export interface ProjectScopeOption {
key: string;
root: string;
displayPath: string;
source: 'local' | 'registry';
}
export type ProjectScopeMode = 'single' | 'aggregate';
export interface ResolveProjectScopeInput {
currentProjectRoot: string;
registryProjects: ProjectScopeRegistryEntry[];
requestedProjectKey?: string | null;
requestedMode?: string | null;
}
export interface ResolvedProjectScope {
mode: ProjectScopeMode;
selected: ProjectScopeOption;
readRoots: string[];
options: ProjectScopeOption[];
}
function normalizeRequestedKey(input?: string | null): string | null {
if (typeof input !== 'string') {
return null;
}
const trimmed = input.trim();
if (!trimmed) {
return null;
}
return trimmed.toLowerCase();
}
function buildLocalOption(currentProjectRoot: string): ProjectScopeOption {
const root = canonicalizeWindowsPath(currentProjectRoot);
return {
key: 'local',
root,
displayPath: toDisplayPath(root),
source: 'local',
};
}
function buildRegistryOptions(registryProjects: ProjectScopeRegistryEntry[]): ProjectScopeOption[] {
const seen = new Set<string>();
const options: ProjectScopeOption[] = [];
for (const project of registryProjects) {
const root = canonicalizeWindowsPath(project.path);
const key = windowsPathKey(root);
if (seen.has(key)) {
continue;
}
seen.add(key);
options.push({
key,
root,
displayPath: toDisplayPath(root),
source: 'registry',
});
}
return options;
}
function normalizeMode(input?: string | null): ProjectScopeMode {
if (input === 'aggregate') {
return 'aggregate';
}
return 'single';
}
export function resolveProjectScope(input: ResolveProjectScopeInput): ResolvedProjectScope {
const local = buildLocalOption(input.currentProjectRoot);
const registry = buildRegistryOptions(input.registryProjects);
const options = [local, ...registry];
const requestedKey = normalizeRequestedKey(input.requestedProjectKey);
const mode = normalizeMode(input.requestedMode);
const readRoots =
mode === 'aggregate' ? options.map((option) => option.root) : [local.root];
if (!requestedKey || requestedKey === 'local') {
return { mode, selected: local, readRoots, options };
}
const selected = options.find((option) => option.key === requestedKey);
const resolvedSelected = selected ?? local;
const resolvedReadRoots =
mode === 'aggregate' ? readRoots : [resolvedSelected.root];
return {
mode,
selected: resolvedSelected,
readRoots: resolvedReadRoots,
options,
};
}

View file

@ -47,6 +47,20 @@ const DEFAULT_IGNORE_DIRECTORIES = [
'artifacts',
'logs',
'.worktrees', // TODO: confirm whether worktrees should be scan targets.
'worktrees',
'.agents',
'.kimi',
'.zenflow',
'.gemini',
'appdata',
];
const DEFAULT_IGNORE_PATH_FRAGMENTS = [
'\\go\\pkg\\mod\\',
'\\.agents\\skills\\',
'\\.kimi\\skills\\',
'\\.gemini\\skills\\',
'\\.zenflow\\worktrees\\',
];
function userProfileRoot(): string {
@ -73,6 +87,18 @@ async function ensureDirectoryExists(input: string): Promise<string | null> {
}
}
async function fileExists(input: string): Promise<boolean> {
try {
const stat = await fs.stat(input);
return stat.isFile();
} catch (error) {
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
return false;
}
throw error;
}
}
async function resolveFullDriveRoots(): Promise<string[]> {
const candidates = ['C:\\', 'D:\\'];
const roots: string[] = [];
@ -128,6 +154,20 @@ function buildIgnoreSet(additional: string[] = []): Set<string> {
);
}
function shouldIgnorePath(dir: string): boolean {
const normalized = toCanonicalRoot(dir).toLowerCase();
return DEFAULT_IGNORE_PATH_FRAGMENTS.some((fragment) => normalized.includes(fragment));
}
function shouldIgnoreDirectoryName(name: string): boolean {
const normalized = name.trim().toLowerCase();
return (
normalized.startsWith('beadboard-read-') ||
normalized.startsWith('beadboard-watch-') ||
normalized.startsWith('skills-')
);
}
function recordProject(projects: Map<string, ScannerProject>, root: string): void {
const normalized = toCanonicalRoot(root);
const key = windowsPathKey(normalized);
@ -155,6 +195,11 @@ async function scanRoot(
continue;
}
if (current.depth > 0 && shouldIgnorePath(current.dir)) {
stats.ignoredDirectories += 1;
continue;
}
stats.scannedDirectories += 1;
let entries: Dirent[];
try {
@ -179,7 +224,7 @@ async function scanRoot(
}
const entryName = entry.name.toLowerCase();
if (ignoreSet.has(entryName)) {
if (ignoreSet.has(entryName) || shouldIgnoreDirectoryName(entryName)) {
stats.ignoredDirectories += 1;
continue;
}
@ -190,7 +235,13 @@ async function scanRoot(
}
if (hasBeads) {
recordProject(projects, current.dir);
const issuesPath = path.join(current.dir, '.beads', 'issues.jsonl');
const fallbackIssuesPath = path.join(current.dir, '.beads', 'issues.jsonl.new');
const [primaryExists, fallbackExists] = await Promise.all([fileExists(issuesPath), fileExists(fallbackIssuesPath)]);
if (primaryExists || fallbackExists) {
recordProject(projects, current.dir);
}
}
}
}