diff --git a/src/components/graph/assignment-panel.tsx b/src/components/graph/assignment-panel.tsx new file mode 100644 index 0000000..87419c5 --- /dev/null +++ b/src/components/graph/assignment-panel.tsx @@ -0,0 +1,366 @@ +"use client"; + +import React, { useState, useMemo } from 'react'; +import { Zap, Users, Blocks, FileCode2, Loader2, UserPlus, Clock, AlertCircle } from 'lucide-react'; +import { ArchetypeInspector } from '../swarm/archetype-inspector'; +import { TemplateInspector } from '../swarm/template-inspector'; +import { useArchetypes } from '../../hooks/use-archetypes'; +import { useTemplates } from '../../hooks/use-templates'; +import { useGraphAnalysis } from '../../hooks/use-graph-analysis'; +import type { BeadIssue } from '../../lib/types'; +import type { AgentArchetype } from '../../lib/types-swarm'; + +export interface AssignmentPanelProps { + selectedIssue: BeadIssue | null; + projectRoot: string; + issues: BeadIssue[]; + epicId?: string; +} + +function hasAgentLabel(labels: string[]): boolean { + return labels.some(label => label.startsWith('agent:')); +} + +function getAgentLabels(labels: string[]): string[] { + return labels.filter(label => label.startsWith('agent:')); +} + +function extractArchetypeIdFromLabel(label: string): string { + return label.replace('agent:', ''); +} + +function truncateTitle(title: string, maxLength: number = 30): string { + if (title.length <= maxLength) return title; + return title.slice(0, maxLength - 3) + '...'; +} + +export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId }: AssignmentPanelProps) { + const [inspectingArchetypeId, setInspectingArchetypeId] = useState(null); + const [inspectingTemplateId, setInspectingTemplateId] = useState(null); + const [selectedArchetypeForPrep, setSelectedArchetypeForPrep] = useState(''); + const [isPrepping, setIsPrepping] = useState(false); + const [prepSuccess, setPrepSuccess] = useState(false); + const [quickAssignDropdown, setQuickAssignDropdown] = useState(null); + + const { archetypes, saveArchetype, deleteArchetype } = useArchetypes(projectRoot); + const { templates, saveTemplate, deleteTemplate } = useTemplates(projectRoot); + const { actionableNodeIds } = useGraphAnalysis(issues, projectRoot, null); + + const needsAgentTasks = useMemo(() => { + return issues.filter(issue => { + if (issue.status === 'closed') return false; + if (!actionableNodeIds.has(issue.id)) return false; + if (hasAgentLabel(issue.labels)) return false; + // Filter by selected epic + if (epicId) { + const hasParentEpic = issue.dependencies.some( + dep => dep.type === 'parent' && dep.target === epicId + ); + if (!hasParentEpic) return false; + } + return true; + }); + }, [issues, actionableNodeIds, epicId]); + + const preAssignedTasks = useMemo(() => { + return issues.filter(issue => { + if (issue.status === 'in_progress') return false; + if (issue.status === 'closed') return false; + if (!hasAgentLabel(issue.labels)) return false; + // Filter by selected epic + if (epicId) { + const hasParentEpic = issue.dependencies.some( + dep => dep.type === 'parent' && dep.target === epicId + ); + if (!hasParentEpic) return false; + } + return true; + }); + }, [issues, epicId]); + + const activeRoster = useMemo(() => { + const filtered = issues.filter(issue => { + if (issue.status !== 'in_progress') return false; + if (!issue.assignee) return false; + if (epicId) { + const hasParentEpic = issue.dependencies.some( + dep => dep.type === 'parent' && dep.target === epicId + ); + if (!hasParentEpic) return false; + } + return true; + }); + + return filtered.map(issue => { + const matchedArchetype = archetypes.find((a: AgentArchetype) => + issue.assignee?.toLowerCase().includes(a.id.toLowerCase()) || + issue.assignee?.toLowerCase().includes(a.name.toLowerCase()) + ); + return { issue, archetype: matchedArchetype }; + }); + }, [issues, archetypes, epicId]); + + const handlePrepTask = async () => { + if (!selectedIssue || !selectedArchetypeForPrep) return; + + setIsPrepping(true); + setPrepSuccess(false); + + try { + const res = await fetch('/api/swarm/prep', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + beadId: selectedIssue.id, + archetypeId: selectedArchetypeForPrep + }) + }); + + if (!res.ok) { + throw new Error('Failed to prep task'); + } + + setPrepSuccess(true); + setTimeout(() => setPrepSuccess(false), 2000); + } catch (error) { + console.error('Failed to prep task:', error); + } finally { + setIsPrepping(false); + } + }; + + const handleQuickAssign = async (issueId: string, archetypeId: string) => { + try { + const res = await fetch('/api/swarm/prep', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + beadId: issueId, + archetypeId: archetypeId + }) + }); + + if (!res.ok) { + throw new Error('Failed to assign agent'); + } + + setQuickAssignDropdown(null); + } catch (error) { + console.error('Failed to assign agent:', error); + } + }; + + const getArchetypeForAgentLabel = (label: string): AgentArchetype | undefined => { + const archetypeId = extractArchetypeIdFromLabel(label); + return archetypes.find((a: AgentArchetype) => + a.id.toLowerCase() === archetypeId.toLowerCase() || + a.name.toLowerCase() === archetypeId.toLowerCase() + ); + }; + + const renderTaskItem = (issue: BeadIssue, showAssignButton: boolean = false, archetypeBadges: AgentArchetype[] = []) => ( +
+
+
{issue.id}
+
{truncateTitle(issue.title)}
+
+ {archetypeBadges.length > 0 && ( +
+ {archetypeBadges.map(archetype => ( +
+ {archetype.name} +
+ ))} +
+ )} + {showAssignButton && ( +
+ + {quickAssignDropdown === issue.id && ( +
+ {archetypes.map((a: AgentArchetype) => ( + + ))} +
+ )} +
+ )} +
+ ); + + return ( +
+
+ + +
+ + {selectedIssue && ( +
+
+ +

Task Assignment

+
+
+
+
{selectedIssue.id}
+
{selectedIssue.title}
+
Status: {selectedIssue.status}
+
+ + {(selectedIssue.status === 'open' || selectedIssue.status === 'blocked') ? ( +
+
+ + +
+ +
+ ) : ( +
+ Task is {selectedIssue.status.replace('_', ' ')}. Only open or blocked tasks can be prepped. +
+ )} +
+
+ )} + +
+
+ +

Needs Agent

+ {needsAgentTasks.length} +
+
+ {needsAgentTasks.length === 0 ? ( +
+ All actionable tasks have agents assigned +
+ ) : ( + needsAgentTasks.map(issue => renderTaskItem(issue, true)) + )} +
+
+ +
+
+ +

Pre-assigned

+ {preAssignedTasks.length} +
+
+ {preAssignedTasks.length === 0 ? ( +
+ No pre-assigned tasks waiting +
+ ) : ( + preAssignedTasks.map(issue => { + const agentLabels = getAgentLabels(issue.labels); + const archetypeBadges = agentLabels + .map(label => getArchetypeForAgentLabel(label)) + .filter((a): a is AgentArchetype => a !== undefined); + return renderTaskItem(issue, false, archetypeBadges); + }) + )} +
+
+ +
+
+ +

Squad Roster

+ {activeRoster.length} active +
+
+ {activeRoster.length === 0 ? ( +
+ No active agents +
+ ) : ( + activeRoster.map(({ issue, archetype }) => ( +
+
+ {archetype?.name.charAt(0) || '?'} +
+
+
{issue.assignee}
+
{issue.id}
+
+
+ )) + )} +
+
+ + {inspectingArchetypeId !== null && ( + a.id === inspectingArchetypeId)} + onClose={() => setInspectingArchetypeId(null)} + onSave={saveArchetype} + onDelete={deleteArchetype} + /> + )} + + {inspectingTemplateId !== null && ( + t.id === inspectingTemplateId)} + archetypes={archetypes} + onClose={() => setInspectingTemplateId(null)} + onSave={saveTemplate} + onDelete={deleteTemplate} + /> + )} +
+ ); +} diff --git a/tests/components/graph/assignment-panel-sections.test.tsx b/tests/components/graph/assignment-panel-sections.test.tsx new file mode 100644 index 0000000..61f87de --- /dev/null +++ b/tests/components/graph/assignment-panel-sections.test.tsx @@ -0,0 +1,31 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs'; +import path from 'path'; + +describe('AssignmentPanel Sections', () => { + const filePath = path.join(process.cwd(), 'src/components/graph/assignment-panel.tsx'); + const source = fs.readFileSync(filePath, 'utf-8'); + + it('imports useGraphAnalysis for actionable detection', () => { + assert.ok(source.includes('useGraphAnalysis'), 'Should import useGraphAnalysis'); + }); + + it('has Needs Agent section header', () => { + assert.ok(source.includes('Needs Agent'), 'Should have Needs Agent section'); + }); + + it('has Pre-assigned section header', () => { + assert.ok(source.includes('Pre-assigned'), 'Should have Pre-assigned section'); + }); + + it('filters Needs Agent to actionable tasks without agent label', () => { + // Should check for agent: label and actionable status + assert.ok(source.includes('actionableNodeIds'), 'Should use actionableNodeIds'); + assert.ok(source.includes('agent:'), 'Should check for agent: labels'); + }); + + it('scopes Active Workers to epicId when provided', () => { + assert.ok(source.includes('epicId'), 'Should use epicId for filtering'); + }); +});