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:
zenchantlive 2026-02-24 16:16:10 -08:00
parent 308a7d9b31
commit 93b3c33976
6 changed files with 706 additions and 79 deletions

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

View file

@ -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>

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