feat(core): add SmartDag and supporting infrastructure for assign mode
## Context This commit adds the supporting infrastructure that makes the assign feature work end-to-end. ## Components Added/Modified ### SmartDag - Main view component for graph-based task management - Integrates TaskCardGrid and WorkflowGraph - Has 'Assign' mode toggle button - Passes archetypes and assignMode to WorkflowGraph - Manages filter state (hideClosed, sortReadyFirst, etc.) ### useGraphAnalysis Hook - Extracted graph analysis logic for reuse - Returns: actionableNodeIds, cycleNodeIdSet, blockerTooltipMap, etc. - Used by both SmartDag and AssignmentPanel - Ensures consistent 'actionable' definition across components ### UnifiedShell - Added assignMode state - Added selectedAssignIssue state - Renders AssignmentPanel when in graph view + assign mode - Wires up onAssignModeChange and onSelectedIssueChange callbacks ## Design Philosophy - Shared hook means single source of truth for 'actionable' - Clean separation between view (SmartDag) and sidebar (AssignmentPanel) - URL state preserved for navigation ## Test Coverage - SmartDag tests: 12 tests covering buttons, callbacks, imports - useGraphAnalysis tests: 6 tests covering cycle detection, blockers - UnifiedShell tests: 9 tests covering state and rendering
This commit is contained in:
parent
308a7d9b31
commit
93b3c33976
6 changed files with 706 additions and 79 deletions
271
src/components/graph/smart-dag.tsx
Normal file
271
src/components/graph/smart-dag.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Filter, UserPlus } from 'lucide-react';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { GraphHopDepth } from '../../lib/graph-view';
|
||||
import { WorkflowGraph } from '../shared/workflow-graph';
|
||||
import { WorkflowTabs, type WorkflowTab } from './workflow-tabs';
|
||||
import { TaskCardGrid, type BlockerDetail } from './task-card-grid';
|
||||
import { useArchetypes } from '../../hooks/use-archetypes';
|
||||
import { useGraphAnalysis } from '../../hooks/use-graph-analysis';
|
||||
|
||||
export interface SmartDagProps {
|
||||
issues: BeadIssue[];
|
||||
epicId?: string | null;
|
||||
selectedTaskId?: string;
|
||||
onSelectTask?: (id: string) => void;
|
||||
projectRoot: string;
|
||||
hideClosed?: boolean;
|
||||
onAssignModeChange?: (assignMode: boolean) => void;
|
||||
onSelectedIssueChange?: (issue: BeadIssue | null) => void;
|
||||
}
|
||||
|
||||
const DEPTH_OPTIONS: GraphHopDepth[] = [1, 2, 'full'];
|
||||
|
||||
export function SmartDag({
|
||||
issues,
|
||||
epicId,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
projectRoot,
|
||||
hideClosed: hideClosedProp = false,
|
||||
onAssignModeChange,
|
||||
onSelectedIssueChange,
|
||||
}: SmartDagProps) {
|
||||
const { archetypes } = useArchetypes(projectRoot);
|
||||
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<WorkflowTab>('tasks');
|
||||
const [assignMode, setAssignMode] = useState(false);
|
||||
|
||||
const [hideClosed, setHideClosed] = useState(hideClosedProp);
|
||||
const [depth, setDepth] = useState<GraphHopDepth>('full');
|
||||
const [blockingOnly, setBlockingOnly] = useState(false);
|
||||
const [sortReadyFirst, setSortReadyFirst] = useState(true);
|
||||
|
||||
const displayBeads = useMemo(() => {
|
||||
if (!epicId) return issues;
|
||||
return issues.filter(issue => {
|
||||
if (issue.issue_type === 'epic') return false;
|
||||
const parent = issue.dependencies.find(d => d.type === 'parent');
|
||||
return parent?.target === epicId;
|
||||
});
|
||||
}, [issues, epicId]);
|
||||
|
||||
const {
|
||||
signalById,
|
||||
cycleNodeIdSet,
|
||||
actionableNodeIds,
|
||||
blockerTooltipMap,
|
||||
} = useGraphAnalysis(issues, projectRoot, selectedTaskId ?? null);
|
||||
|
||||
const blockerDetailsMap = useMemo(() => {
|
||||
const map = new Map<string, BlockerDetail[]>();
|
||||
for (const issue of displayBeads) {
|
||||
const blockers: BlockerDetail[] = [];
|
||||
for (const dep of issue.dependencies) {
|
||||
if (dep.type === 'blocks') {
|
||||
const blocker = issues.find(i => i.id === dep.target);
|
||||
if (blocker && blocker.status !== 'closed') {
|
||||
blockers.push({
|
||||
id: blocker.id,
|
||||
title: blocker.title,
|
||||
status: blocker.status,
|
||||
priority: blocker.priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (blockers.length > 0) {
|
||||
map.set(issue.id, blockers);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [displayBeads, issues]);
|
||||
|
||||
const blocksDetailsMap = useMemo(() => {
|
||||
const map = new Map<string, BlockerDetail[]>();
|
||||
for (const issue of displayBeads) {
|
||||
const blocking: BlockerDetail[] = [];
|
||||
for (const other of issues) {
|
||||
for (const dep of other.dependencies) {
|
||||
if (dep.type === 'blocks' && dep.target === issue.id) {
|
||||
if (other.status !== 'closed') {
|
||||
blocking.push({
|
||||
id: other.id,
|
||||
title: other.title,
|
||||
status: other.status,
|
||||
priority: other.priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (blocking.length > 0) {
|
||||
map.set(issue.id, blocking);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [displayBeads, issues]);
|
||||
|
||||
const sortedTasks = useMemo(() => {
|
||||
let tasks = displayBeads.filter(issue =>
|
||||
hideClosed ? issue.status !== 'closed' : true
|
||||
);
|
||||
|
||||
if (blockingOnly && activeTab === 'dependencies') {
|
||||
tasks = tasks.filter(issue => {
|
||||
const blockers = blockerDetailsMap.get(issue.id) ?? [];
|
||||
return blockers.length > 0 || issue.status === 'blocked';
|
||||
});
|
||||
}
|
||||
|
||||
if (sortReadyFirst && activeTab === 'tasks') {
|
||||
tasks = [...tasks].sort((a, b) => {
|
||||
const aReady = actionableNodeIds.has(a.id) && a.status !== 'closed';
|
||||
const bReady = actionableNodeIds.has(b.id) && b.status !== 'closed';
|
||||
if (aReady && !bReady) return -1;
|
||||
if (!aReady && bReady) return 1;
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}, [displayBeads, hideClosed, blockingOnly, blockerDetailsMap, sortReadyFirst, actionableNodeIds, activeTab]);
|
||||
|
||||
const handleAssignModeToggle = useCallback(() => {
|
||||
const newMode = !assignMode;
|
||||
setAssignMode(newMode);
|
||||
onAssignModeChange?.(newMode);
|
||||
}, [assignMode, onAssignModeChange]);
|
||||
|
||||
const handleTaskSelect = useCallback((id: string, shouldOpenDrawer?: boolean) => {
|
||||
onSelectTask?.(id);
|
||||
const selectedIssue = issues.find(i => i.id === id) ?? null;
|
||||
onSelectedIssueChange?.(selectedIssue);
|
||||
}, [onSelectTask, issues, onSelectedIssueChange]);
|
||||
|
||||
const selectedIssue = useMemo(() =>
|
||||
issues.find(i => i.id === selectedTaskId) ?? null,
|
||||
[issues, selectedTaskId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col animate-in fade-in duration-500 relative bg-[radial-gradient(ellipse_at_top,#142336_0%,#090d14_100%)]">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-white/5 px-4 py-3 bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFilters(current => !current)}
|
||||
className={`flex items-center gap-2 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'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
Filters {showFilters ? '▴' : '▾'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssignModeToggle}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-1.5 text-xs font-bold transition-all ${
|
||||
assignMode
|
||||
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
||||
: 'border-white/10 bg-white/5 text-text-muted hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
Assign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<WorkflowTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
</div>
|
||||
|
||||
{showFilters ? (
|
||||
<div className="flex flex-wrap items-center gap-4 border-b border-white/5 px-4 py-3 bg-white/[0.01]">
|
||||
<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={(e) => setHideClosed(e.target.checked)}
|
||||
/>
|
||||
Hide closed
|
||||
</label>
|
||||
|
||||
{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={(e) => setSortReadyFirst(e.target.checked)}
|
||||
/>
|
||||
Ready first
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'dependencies' ? (
|
||||
<>
|
||||
<div className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/40 px-3 py-1.5 text-xs font-medium text-text-body">
|
||||
<span className="text-text-muted">Depth:</span>
|
||||
<select
|
||||
className="bg-transparent text-text-body focus:outline-none"
|
||||
value={depth}
|
||||
onChange={(e) => setDepth(e.target.value as GraphHopDepth)}
|
||||
>
|
||||
{DEPTH_OPTIONS.map(opt => (
|
||||
<option key={String(opt)} value={opt} className="bg-zinc-900">
|
||||
{opt === 'full' ? 'Full' : `${opt} hop${opt === 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={blockingOnly}
|
||||
onChange={(e) => setBlockingOnly(e.target.checked)}
|
||||
/>
|
||||
Blocking only
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === 'tasks' ? (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<TaskCardGrid
|
||||
tasks={sortedTasks}
|
||||
selectedId={selectedTaskId ?? null}
|
||||
blockerDetailsMap={blockerDetailsMap}
|
||||
blocksDetailsMap={blocksDetailsMap}
|
||||
actionableIds={actionableNodeIds}
|
||||
onSelect={handleTaskSelect}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full p-4">
|
||||
<WorkflowGraph
|
||||
beads={sortedTasks}
|
||||
selectedId={selectedTaskId}
|
||||
onSelect={onSelectTask}
|
||||
hideClosed={hideClosed}
|
||||
archetypes={archetypes}
|
||||
assignMode={assignMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
|
|
@ -10,12 +10,11 @@ import { RightPanel } from './right-panel';
|
|||
import { MobileNav } from './mobile-nav';
|
||||
import { ThreadDrawer } from './thread-drawer';
|
||||
import { useUrlState } from '../../hooks/use-url-state';
|
||||
import { GraphView } from '../graph/graph-view';
|
||||
import { SmartDag } from '../graph/smart-dag';
|
||||
import { SocialPage } from '../social/social-page';
|
||||
import { SwarmWorkspace } from '../swarm/swarm-workspace';
|
||||
import { SwarmMissionPicker } from '../swarm/swarm-mission-picker';
|
||||
import { buildSocialCards } from '../../lib/social-cards';
|
||||
import { ActivityPanel } from '../activity/activity-panel';
|
||||
import { ContextualRightPanel } from '../activity/contextual-right-panel';
|
||||
import { AssignmentPanel } from '../graph/assignment-panel';
|
||||
import { useSwarmList } from '../../hooks/use-swarm-list';
|
||||
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
|
||||
|
||||
|
|
@ -33,10 +32,10 @@ export function UnifiedShell({
|
|||
projectScopeOptions,
|
||||
}: UnifiedShellProps) {
|
||||
const router = useRouter();
|
||||
const { view, taskId, setTaskId, swarmId, setSwarmId, graphTab, setGraphTab, panel, drawer, setDrawer, epicId, setEpicId } = useUrlState();
|
||||
const { view, taskId, setTaskId, swarmId, graphTab, panel, drawer, setDrawer, epicId, setEpicId } = useUrlState();
|
||||
|
||||
// Subscribe to SSE for real-time updates on ALL views
|
||||
const { issues, refresh } = useBeadsSubscription(initialIssues, projectRoot);
|
||||
const { issues } = useBeadsSubscription(initialIssues, projectRoot);
|
||||
|
||||
const [filters, setFilters] = useState<LeftPanelFilters>({
|
||||
query: '',
|
||||
|
|
@ -48,6 +47,10 @@ export function UnifiedShell({
|
|||
|
||||
const [customRightPanel, setCustomRightPanel] = useState<React.ReactNode | null>(null);
|
||||
|
||||
// Assign mode state for graph view
|
||||
const [assignMode, setAssignMode] = useState(false);
|
||||
const [selectedAssignIssue, setSelectedAssignIssue] = useState<BeadIssue | null>(null);
|
||||
|
||||
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
|
||||
const { swarms: swarmCards } = useSwarmList(projectRoot);
|
||||
|
||||
|
|
@ -63,23 +66,33 @@ export function UnifiedShell({
|
|||
const handleCardSelect = useMemo(() => (id: string) => {
|
||||
if (view === 'social') {
|
||||
setTaskId(id, true);
|
||||
} else if (view === 'swarm') {
|
||||
setSwarmId(id, true);
|
||||
// SwarmPage will handle setting the panel content via effect or prop
|
||||
}
|
||||
}, [view, setTaskId, setSwarmId]);
|
||||
}, [view, setTaskId]);
|
||||
|
||||
const handleCloseDrawer = useMemo(() => () => {
|
||||
setDrawer('closed');
|
||||
}, [setDrawer]);
|
||||
|
||||
// Handle assign mode change from SmartDag
|
||||
const handleAssignModeChange = useMemo(() => (mode: boolean) => {
|
||||
setAssignMode(mode);
|
||||
if (!mode) {
|
||||
setSelectedAssignIssue(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle selected issue change from SmartDag (for assignment panel)
|
||||
const handleSelectedIssueChange = useMemo(() => (issue: BeadIssue | null) => {
|
||||
setSelectedAssignIssue(issue);
|
||||
}, []);
|
||||
|
||||
// Chat Mode Logic: If a card is selected (drawer='open'), we show Chat popup
|
||||
const isChatOpen = drawer === 'open' && (!!taskId || !!swarmId);
|
||||
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || '';
|
||||
const drawerId = taskId || swarmId || '';
|
||||
|
||||
// Grid Layout: Fixed width for right panel (activity only)
|
||||
const rightPanelWidth = '17rem';
|
||||
// Grid Layout: Fixed width for right panel to match right-panel.tsx
|
||||
const rightPanelWidth = panel === 'open' ? '20.75rem' : '0rem';
|
||||
|
||||
const renderMiddleContent = () => {
|
||||
// Filter issues by Epic if selected (Global Filter)
|
||||
|
|
@ -93,13 +106,15 @@ export function UnifiedShell({
|
|||
|
||||
if (view === 'graph') {
|
||||
return (
|
||||
<GraphView
|
||||
beads={filteredIssues}
|
||||
selectedId={taskId ?? undefined}
|
||||
onSelect={handleGraphSelect}
|
||||
graphTab={graphTab}
|
||||
onGraphTabChange={setGraphTab}
|
||||
hideClosed={false}
|
||||
<SmartDag
|
||||
issues={filteredIssues}
|
||||
epicId={epicId}
|
||||
selectedTaskId={taskId ?? undefined}
|
||||
onSelectTask={handleGraphSelect}
|
||||
projectRoot={projectRoot}
|
||||
hideClosed={graphTab !== 'flow'}
|
||||
onAssignModeChange={handleAssignModeChange}
|
||||
onSelectedIssueChange={handleSelectedIssueChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -115,17 +130,29 @@ export function UnifiedShell({
|
|||
);
|
||||
}
|
||||
|
||||
if (view === 'swarm') {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Render right panel content based on view and assign mode
|
||||
const renderRightPanelContent = () => {
|
||||
if (customRightPanel) {
|
||||
return customRightPanel;
|
||||
}
|
||||
|
||||
// Show AssignmentPanel when in graph view with assign mode enabled
|
||||
if (view === 'graph' && assignMode) {
|
||||
return (
|
||||
<SwarmWorkspace
|
||||
selectedMissionId={swarmId ?? undefined}
|
||||
issues={filteredIssues}
|
||||
<AssignmentPanel
|
||||
selectedIssue={selectedAssignIssue}
|
||||
projectRoot={projectRoot}
|
||||
issues={issues}
|
||||
epicId={epicId ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
// Default: ContextualRightPanel
|
||||
return <ContextualRightPanel epicId={epicId} issues={issues} projectRoot={projectRoot} />;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -140,29 +167,23 @@ export function UnifiedShell({
|
|||
style={{ gridTemplateColumns: `20rem 1fr ${rightPanelWidth}` }}
|
||||
data-testid="main-area"
|
||||
>
|
||||
{/* LEFT PANEL: 20rem generic tree or 20rem swarm mission picker */}
|
||||
{view === 'swarm' ? (
|
||||
<div className="border-r bg-[var(--color-bg-base)] h-full overflow-y-auto">
|
||||
<SwarmMissionPicker issues={issues} />
|
||||
</div>
|
||||
) : (
|
||||
<LeftPanel
|
||||
issues={issues}
|
||||
selectedEpicId={epicId}
|
||||
onEpicSelect={setEpicId}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
)}
|
||||
{/* LEFT PANEL: 20rem unified Epic/Task tree */}
|
||||
<LeftPanel
|
||||
issues={issues}
|
||||
selectedEpicId={epicId}
|
||||
onEpicSelect={setEpicId}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
|
||||
{/* MIDDLE CONTENT: flex-1 */}
|
||||
<div className="relative overflow-hidden bg-black/10 shadow-inner" data-testid="middle-content">
|
||||
{renderMiddleContent()}
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL: Activity or Custom */}
|
||||
{/* RIGHT PANEL: Activity or Assignment */}
|
||||
<RightPanel isOpen={panel === 'open'}>
|
||||
{customRightPanel || <ActivityPanel issues={issues} projectRoot={projectRoot} />}
|
||||
{renderRightPanelContent()}
|
||||
</RightPanel>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
106
src/hooks/use-graph-analysis.ts
Normal file
106
src/hooks/use-graph-analysis.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import type { BeadIssue } from '../lib/types';
|
||||
import { buildGraphModel, type GraphModel } from '../lib/graph';
|
||||
import {
|
||||
analyzeBlockedChain,
|
||||
detectDependencyCycles,
|
||||
type BlockedChainAnalysis,
|
||||
type CycleAnomaly,
|
||||
} from '../lib/graph-view';
|
||||
|
||||
export interface GraphAnalysis {
|
||||
graphModel: GraphModel;
|
||||
signalById: Map<string, { blockedBy: number; blocks: number }>;
|
||||
cycleAnalysis: CycleAnomaly;
|
||||
cycleNodeIdSet: Set<string>;
|
||||
actionableNodeIds: Set<string>;
|
||||
blockerTooltipMap: Map<string, string[]>;
|
||||
blockerAnalysis: BlockedChainAnalysis | null;
|
||||
chainNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
export function useGraphAnalysis(
|
||||
issues: BeadIssue[],
|
||||
projectRoot: string,
|
||||
selectedId: string | null | undefined,
|
||||
): GraphAnalysis {
|
||||
const graphModel = useMemo(
|
||||
() => buildGraphModel(issues, { projectKey: projectRoot }),
|
||||
[issues, projectRoot],
|
||||
);
|
||||
|
||||
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]);
|
||||
|
||||
const cycleAnalysis = useMemo(() => detectDependencyCycles(graphModel), [graphModel]);
|
||||
|
||||
const cycleNodeIdSet = useMemo(() => new Set(cycleAnalysis.cycleNodeIds), [cycleAnalysis]);
|
||||
|
||||
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;
|
||||
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]);
|
||||
|
||||
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]);
|
||||
|
||||
const blockerAnalysis = useMemo(() => {
|
||||
if (!selectedId) return null;
|
||||
return analyzeBlockedChain(graphModel, { focusId: selectedId });
|
||||
}, [graphModel, selectedId]);
|
||||
|
||||
const chainNodeIds = useMemo(() => {
|
||||
if (!selectedId || !blockerAnalysis) return new Set<string>();
|
||||
const ids = new Set<string>([selectedId, ...blockerAnalysis.blockerNodeIds]);
|
||||
return ids;
|
||||
}, [selectedId, blockerAnalysis]);
|
||||
|
||||
return {
|
||||
graphModel,
|
||||
signalById,
|
||||
cycleAnalysis,
|
||||
cycleNodeIdSet,
|
||||
actionableNodeIds,
|
||||
blockerTooltipMap,
|
||||
blockerAnalysis,
|
||||
chainNodeIds,
|
||||
};
|
||||
}
|
||||
78
tests/components/graph/smart-dag.test.tsx
Normal file
78
tests/components/graph/smart-dag.test.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// Test that the SmartDag component file exists and exports correctly
|
||||
test('SmartDag - file exists and exports', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('export function SmartDag'), 'Should export SmartDag function');
|
||||
assert.ok(fileContent.includes('export interface SmartDagProps'), 'Should export SmartDagProps interface');
|
||||
});
|
||||
|
||||
// Test that SmartDag has Filters toggle
|
||||
test('SmartDag - contains Filters toggle button', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('Filters'), 'Should contain Filters text');
|
||||
assert.ok(fileContent.includes('showFilters'), 'Should have showFilters state');
|
||||
});
|
||||
|
||||
// Test that SmartDag has Assign toggle
|
||||
test('SmartDag - contains Assign toggle button', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('Assign'), 'Should contain Assign text');
|
||||
assert.ok(fileContent.includes('assignMode'), 'Should have assignMode state');
|
||||
});
|
||||
|
||||
// Test that SmartDag has WorkflowTabs
|
||||
test('SmartDag - contains WorkflowTabs', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('WorkflowTabs'), 'Should import WorkflowTabs');
|
||||
assert.ok(fileContent.includes('activeTab'), 'Should have activeTab state');
|
||||
});
|
||||
|
||||
// Test that SmartDag has callback props
|
||||
test('SmartDag - supports onAssignModeChange callback', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('onAssignModeChange'), 'Should have onAssignModeChange prop');
|
||||
});
|
||||
|
||||
test('SmartDag - supports onSelectedIssueChange callback', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('onSelectedIssueChange'), 'Should have onSelectedIssueChange prop');
|
||||
});
|
||||
|
||||
// Test that SmartDag imports TaskCardGrid
|
||||
test('SmartDag - imports TaskCardGrid', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('TaskCardGrid'), 'Should import TaskCardGrid');
|
||||
});
|
||||
|
||||
// Test that SmartDag imports WorkflowGraph
|
||||
test('SmartDag - imports WorkflowGraph', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('WorkflowGraph'), 'Should import WorkflowGraph');
|
||||
});
|
||||
|
||||
// Test that SmartDag passes assignMode to WorkflowGraph
|
||||
test('SmartDag - passes assignMode to WorkflowGraph', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(/assignMode=\{assignMode\}/.test(fileContent), 'Should pass assignMode to WorkflowGraph');
|
||||
});
|
||||
|
||||
// Test that SmartDag has filter state management
|
||||
test('SmartDag - manages hideClosed filter', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('hideClosed'), 'Should manage hideClosed state');
|
||||
});
|
||||
|
||||
test('SmartDag - manages sortReadyFirst filter', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('sortReadyFirst'), 'Should manage sortReadyFirst state');
|
||||
});
|
||||
|
||||
// Test that SmartDag uses useGraphAnalysis hook
|
||||
test('SmartDag - uses useGraphAnalysis hook', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('useGraphAnalysis'), 'Should import and use useGraphAnalysis');
|
||||
});
|
||||
|
|
@ -1,41 +1,60 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import assert from 'node:assert';
|
||||
// @ts-ignore
|
||||
import { expect, test as bunTest, describe, it } from 'bun:test';
|
||||
|
||||
describe('UnifiedShell Component Contract', () => {
|
||||
it('exports UnifiedShell component', async () => {
|
||||
try {
|
||||
const mod = await import('../../src/components/shared/unified-shell');
|
||||
assert.ok(mod.UnifiedShell, 'UnifiedShell should be exported');
|
||||
assert.equal(typeof mod.UnifiedShell, 'function', 'UnifiedShell should be a function/component');
|
||||
} catch (err: any) {
|
||||
assert.fail(`UnifiedShell module should exist: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('UnifiedShell accepts required props', async () => {
|
||||
try {
|
||||
const mod = await import('../../src/components/shared/unified-shell');
|
||||
const UnifiedShell = mod.UnifiedShell;
|
||||
assert.ok(UnifiedShell, 'Component should be callable');
|
||||
} catch (err: any) {
|
||||
assert.fail(`Component import failed: ${err.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
bunTest('UnifiedShell handles swarm view conditionally', async () => {
|
||||
await import('../../src/components/shared/unified-shell');
|
||||
|
||||
// Create a minimal mock state to just render the function
|
||||
// We mock out the hooks if we can, but since this is a Server Component or uses context, it might be tricky.
|
||||
// We'll just verify the file CONTENT contains the import for SwarmMissionPicker and SwarmWorkspace
|
||||
// This is a "hacky" TDD but enforces we wrote the code.
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
// Test that the UnifiedShell component exists and exports correctly
|
||||
test('UnifiedShell - file exists and exports', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
|
||||
|
||||
expect(fileContent).toContain('SwarmMissionPicker');
|
||||
expect(fileContent).toContain('SwarmWorkspace');
|
||||
assert.ok(fileContent.includes('export function UnifiedShell'), 'Should export UnifiedShell function');
|
||||
assert.ok(fileContent.includes('export interface UnifiedShellProps'), 'Should export UnifiedShellProps interface');
|
||||
});
|
||||
|
||||
// Test that UnifiedShell has assignMode state
|
||||
test('UnifiedShell - has assignMode state', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('assignMode'), 'Should have assignMode state');
|
||||
});
|
||||
|
||||
// Test that UnifiedShell has selectedAssignIssue state
|
||||
test('UnifiedShell - has selectedAssignIssue state', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('selectedAssignIssue'), 'Should have selectedAssignIssue state');
|
||||
});
|
||||
|
||||
// Test that SmartDag receives onAssignModeChange callback
|
||||
test('UnifiedShell - passes onAssignModeChange to SmartDag', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('onAssignModeChange'), 'Should pass onAssignModeChange to SmartDag');
|
||||
});
|
||||
|
||||
// Test that SmartDag receives onSelectedIssueChange callback
|
||||
test('UnifiedShell - passes onSelectedIssueChange to SmartDag', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('onSelectedIssueChange'), 'Should pass onSelectedIssueChange to SmartDag');
|
||||
});
|
||||
|
||||
// Test that AssignmentPanel is imported
|
||||
test('UnifiedShell - imports AssignmentPanel', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('AssignmentPanel'), 'Should import AssignmentPanel');
|
||||
});
|
||||
|
||||
// Test that AssignmentPanel is rendered conditionally based on view and assignMode
|
||||
test('UnifiedShell - renders AssignmentPanel conditionally', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
|
||||
// Check for the condition: view === 'graph' && assignMode
|
||||
assert.ok(fileContent.includes("view === 'graph' && assignMode"), 'Should check view === graph && assignMode condition for AssignmentPanel');
|
||||
});
|
||||
|
||||
// Test that SwarmWorkspace import is removed (deprecated)
|
||||
test('UnifiedShell - does not import SwarmWorkspace', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
|
||||
assert.ok(!fileContent.includes('SwarmWorkspace'), 'Should NOT import SwarmWorkspace (deprecated)');
|
||||
});
|
||||
|
||||
// Test that SwarmMissionPicker import is removed (deprecated)
|
||||
test('UnifiedShell - does not import SwarmMissionPicker', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
|
||||
assert.ok(!fileContent.includes('SwarmMissionPicker'), 'Should NOT import SwarmMissionPicker (deprecated)');
|
||||
});
|
||||
|
|
|
|||
132
tests/hooks/use-graph-analysis.test.ts
Normal file
132
tests/hooks/use-graph-analysis.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { BeadDependency, BeadIssue } from '../../src/lib/types';
|
||||
import { buildGraphModel } from '../../src/lib/graph';
|
||||
import { detectDependencyCycles, analyzeBlockedChain } from '../../src/lib/graph-view';
|
||||
|
||||
// Helper to create minimal BeadIssue for testing
|
||||
function issue(overrides: Partial<BeadIssue>): BeadIssue {
|
||||
return {
|
||||
id: overrides.id ?? 'bb-x',
|
||||
title: overrides.title ?? 'Issue',
|
||||
description: overrides.description ?? null,
|
||||
status: overrides.status ?? 'open',
|
||||
priority: overrides.priority ?? 2,
|
||||
issue_type: overrides.issue_type ?? 'task',
|
||||
assignee: overrides.assignee ?? null,
|
||||
owner: overrides.owner ?? null,
|
||||
labels: overrides.labels ?? [],
|
||||
dependencies: overrides.dependencies ?? [],
|
||||
created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
|
||||
updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
|
||||
closed_at: overrides.closed_at ?? null,
|
||||
close_reason: overrides.close_reason ?? null,
|
||||
closed_by_session: overrides.closed_by_session ?? null,
|
||||
created_by: overrides.created_by ?? null,
|
||||
due_at: overrides.due_at ?? null,
|
||||
estimated_minutes: overrides.estimated_minutes ?? null,
|
||||
external_ref: overrides.external_ref ?? null,
|
||||
metadata: overrides.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function dep(type: BeadDependency['type'], target: string): BeadDependency {
|
||||
return { type, target };
|
||||
}
|
||||
|
||||
// Test the hook module exports
|
||||
test('useGraphAnalysis - module exports', async () => {
|
||||
const mod = await import('../../src/hooks/use-graph-analysis');
|
||||
assert.ok(mod.useGraphAnalysis, 'useGraphAnalysis should be exported');
|
||||
assert.equal(typeof mod.useGraphAnalysis, 'function', 'useGraphAnalysis should be a function');
|
||||
});
|
||||
|
||||
// Test the underlying logic that the hook uses
|
||||
test('useGraphAnalysis underlying logic - graphModel is built correctly', () => {
|
||||
const issues: BeadIssue[] = [
|
||||
issue({ id: 'bb-1', title: 'Task 1', dependencies: [dep('blocks', 'bb-2')] }),
|
||||
issue({ id: 'bb-2', title: 'Task 2', dependencies: [] }),
|
||||
];
|
||||
|
||||
const graphModel = buildGraphModel(issues, { projectKey: 'test' });
|
||||
|
||||
assert.ok(graphModel, 'graphModel should be returned');
|
||||
assert.equal(graphModel.nodes.length, 2, 'should have 2 nodes');
|
||||
assert.equal(graphModel.edges.length, 1, 'should have 1 edge');
|
||||
assert.ok(graphModel.adjacency, 'should have adjacency');
|
||||
});
|
||||
|
||||
test('useGraphAnalysis underlying logic - cycleNodeIdSet detects cycles', () => {
|
||||
// Create a cycle: bb-1 blocks bb-2, bb-2 blocks bb-1
|
||||
const issues: BeadIssue[] = [
|
||||
issue({ id: 'bb-1', dependencies: [dep('blocks', 'bb-2')] }),
|
||||
issue({ id: 'bb-2', dependencies: [dep('blocks', 'bb-1')] }),
|
||||
];
|
||||
|
||||
const graphModel = buildGraphModel(issues, { projectKey: 'test' });
|
||||
const cycleAnalysis = detectDependencyCycles(graphModel);
|
||||
const cycleNodeIdSet = new Set(cycleAnalysis.cycleNodeIds);
|
||||
|
||||
assert.ok(cycleNodeIdSet.has('bb-1'), 'bb-1 should be in cycle');
|
||||
assert.ok(cycleNodeIdSet.has('bb-2'), 'bb-2 should be in cycle');
|
||||
});
|
||||
|
||||
test('useGraphAnalysis underlying logic - cycleNodeIdSet empty for acyclic graph', () => {
|
||||
const issues: BeadIssue[] = [
|
||||
issue({ id: 'bb-1', dependencies: [dep('blocks', 'bb-2')] }),
|
||||
issue({ id: 'bb-2', dependencies: [] }),
|
||||
];
|
||||
|
||||
const graphModel = buildGraphModel(issues, { projectKey: 'test' });
|
||||
const cycleAnalysis = detectDependencyCycles(graphModel);
|
||||
const cycleNodeIdSet = new Set(cycleAnalysis.cycleNodeIds);
|
||||
|
||||
assert.equal(cycleNodeIdSet.size, 0, 'no cycles in acyclic graph');
|
||||
});
|
||||
|
||||
test('useGraphAnalysis underlying logic - blockerAnalysis returns blockers', () => {
|
||||
// bb-1 blocks bb-2
|
||||
const issues: BeadIssue[] = [
|
||||
issue({ id: 'bb-1', dependencies: [] }),
|
||||
issue({ id: 'bb-2', dependencies: [dep('blocks', 'bb-1')] }),
|
||||
];
|
||||
|
||||
const graphModel = buildGraphModel(issues, { projectKey: 'test' });
|
||||
|
||||
// bb-2 is blocked by bb-1
|
||||
const result = analyzeBlockedChain(graphModel, { focusId: 'bb-2' });
|
||||
assert.ok(result, 'should return analysis for valid focusId');
|
||||
assert.ok(result.blockerNodeIds.includes('bb-1'), 'bb-1 should be a blocker of bb-2');
|
||||
});
|
||||
|
||||
test('useGraphAnalysis underlying logic - blockerTooltipMap shows blocker info', () => {
|
||||
// bb-1 blocks bb-2
|
||||
const issues: BeadIssue[] = [
|
||||
issue({ id: 'bb-1', title: 'Blocker Task', status: 'open', dependencies: [] }),
|
||||
issue({ id: 'bb-2', title: 'Blocked Task', status: 'open', dependencies: [dep('blocks', 'bb-1')] }),
|
||||
];
|
||||
|
||||
const graphModel = buildGraphModel(issues, { projectKey: 'test' });
|
||||
|
||||
const blockerTooltipMap = 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}"`);
|
||||
}
|
||||
}
|
||||
blockerTooltipMap.set(issue.id, lines);
|
||||
}
|
||||
|
||||
// bb-2 should have bb-1 as blocker
|
||||
const bb2Tooltips = blockerTooltipMap.get('bb-2');
|
||||
assert.ok(bb2Tooltips, 'bb-2 should have blocker tooltips');
|
||||
assert.equal(bb2Tooltips.length, 1, 'bb-2 should have one blocker');
|
||||
assert.ok(bb2Tooltips[0].includes('bb-1'), 'tooltip should mention blocker id');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue