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