From 512a836db4f1aaf8472a3f76eef031264d100271 Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Tue, 24 Feb 2026 16:14:56 -0800 Subject: [PATCH] feat(graph): add Assign button and archetype dropdown to GraphNodeCard ## The Collaboration Story User requested ability to assign agent archetypes to tasks directly from graph nodes. This was the core UI feature request. ## Design Decisions Made Together 1. **Placement**: We decided to put the assign UI at the bottom of the card to not interfere with existing status/badges display 2. **Pattern**: Used Radix dropdown-menu (already in project) for consistency 3. **Visual feedback**: Added loading spinner during API calls, success/error messages that auto-dismiss 4. **Closed tasks**: Excluded closed tasks from assignment (logical constraint) ## Issues We Encountered - Initially only added POST handler for assignment - User pointed out we needed DELETE for unassign - added that - The unassign button (X) needed to call DELETE not POST ## API Changes - Added DELETE handler to /api/swarm/prep for removing agent assignments - Uses 'bd label remove' under the hood ## What the UI Shows - Dropdown with available archetypes - Badge showing assigned archetype name with color - X button to unassign (on each badge) - 'Assigned' label on already-assigned archetypes in dropdown ## Test Coverage - Added graph-node-assign.test.tsx with 6 TDD tests ## Beads: beadboard-brq (closed) --- src/app/api/swarm/prep/route.ts | 28 ++- src/components/graph/graph-node-card.tsx | 222 ++++++++++++++++-- .../graph/graph-node-assign.test.tsx | 66 ++++++ 3 files changed, 297 insertions(+), 19 deletions(-) create mode 100644 tests/components/graph/graph-node-assign.test.tsx diff --git a/src/app/api/swarm/prep/route.ts b/src/app/api/swarm/prep/route.ts index 2249cf4..7c4459f 100644 --- a/src/app/api/swarm/prep/route.ts +++ b/src/app/api/swarm/prep/route.ts @@ -17,7 +17,7 @@ export async function POST(request: Request) { const { stdout, stderr } = await execAsync(cmd); if (stderr && !stderr.includes('Warning')) { - console.error('bd edit stderr:', stderr); + console.error('bd label add stderr:', stderr); } return NextResponse.json({ success: true, message: `Prepped ${beadId} for ${archetypeId}`, output: stdout }); @@ -27,3 +27,29 @@ export async function POST(request: Request) { return NextResponse.json({ error: error.message }, { status: 500 }); } } + +export async function DELETE(request: Request) { + try { + const { beadId, archetypeId } = await request.json(); + + if (!beadId) { + return NextResponse.json({ error: 'Missing beadId' }, { status: 400 }); + } + + // Remove the agent: label + // If archetypeId is provided, remove specific label, otherwise remove any agent: label + const labelToRemove = archetypeId ? `agent:${archetypeId}` : 'agent:'; + const cmd = `bd label remove ${beadId} "${labelToRemove}"`; + const { stdout, stderr } = await execAsync(cmd); + + if (stderr && !stderr.includes('Warning')) { + console.error('bd label remove stderr:', stderr); + } + + return NextResponse.json({ success: true, message: `Removed assignment from ${beadId}`, output: stdout }); + + } catch (error: any) { + console.error('Remove assignment failed:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/components/graph/graph-node-card.tsx b/src/components/graph/graph-node-card.tsx index cb96b58..917ca69 100644 --- a/src/components/graph/graph-node-card.tsx +++ b/src/components/graph/graph-node-card.tsx @@ -1,9 +1,11 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; - +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { Loader2, ChevronDown, UserPlus, X } from 'lucide-react'; import type { BeadIssue } from '../../lib/types'; +import type { AgentArchetype } from '../../lib/types-swarm'; /** Data payload for each custom ReactFlow node. */ export interface GraphNodeData { @@ -29,6 +31,15 @@ export interface GraphNodeData { isDimmed: boolean; /** Tooltip lines describing blocker details for hover display. */ blockerTooltipLines: string[]; + /** Labels attached to this node, including agent assignments (agent:archetype-id). */ + labels: string[]; + /** Available agent archetypes for assignment. */ + archetypes?: AgentArchetype[]; +} + +function getAssignedArchetypes(labels: string[], archetypes: AgentArchetype[]): AgentArchetype[] { + const ids = labels.filter(l => l.startsWith('agent:')).map(l => l.replace('agent:', '')); + return archetypes.filter(a => ids.includes(a.id)); } /** @@ -70,10 +81,95 @@ function nodeStyle(kind: GraphNodeData['kind']): string { * - Hover tooltip showing blocker details or "Ready to work" * - Pulse animation on selection * - Dim effect when not in the selected dependency chain + * - Agent archetype assignment badges and dropdown */ export function GraphNodeCard({ id, data, selected }: NodeProps>) { - // Track hover state for tooltip visibility const [hovered, setHovered] = useState(false); + const [isAssigning, setIsAssigning] = useState(false); + const [assignError, setAssignError] = useState(null); + const [assignSuccess, setAssignSuccess] = useState(null); + + // Local state for labels with optimistic updates + const [localLabels, setLocalLabels] = useState(data.labels ?? []); + + // Sync local labels when parent data changes (e.g., on page refresh) + useEffect(() => { + setLocalLabels(data.labels ?? []); + }, [data.labels]); + + const archetypes = data.archetypes ?? []; + const assignedArchetypes = getAssignedArchetypes(localLabels, archetypes); + const isClosed = data.status === 'closed'; + + const handleAssignAgent = async (archetypeId: string) => { + setIsAssigning(true); + setAssignError(null); + setAssignSuccess(null); + + // Optimistic update + const labelToAdd = `agent:${archetypeId}`; + if (!localLabels.includes(labelToAdd)) { + setLocalLabels([...localLabels, labelToAdd]); + } + + try { + const response = await fetch('/api/swarm/prep', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ beadId: id, archetypeId }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error ?? 'Failed to assign agent'); + } + + const archetype = archetypes.find(a => a.id === archetypeId); + setAssignSuccess(`Assigned ${archetype?.name ?? archetypeId}`); + setTimeout(() => setAssignSuccess(null), 2000); + } catch (err) { + // Revert on error + setLocalLabels(prev => prev.filter(l => l !== labelToAdd)); + setAssignError(err instanceof Error ? err.message : 'Failed to assign agent'); + setTimeout(() => setAssignError(null), 3000); + } finally { + setIsAssigning(false); + } + }; + + const handleUnassignAgent = async (archetypeId: string) => { + setIsAssigning(true); + setAssignError(null); + setAssignSuccess(null); + + // Optimistic update + const labelToRemove = `agent:${archetypeId}`; + setLocalLabels(prev => prev.filter(l => l !== labelToRemove)); + + try { + const response = await fetch('/api/swarm/prep', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ beadId: id, archetypeId }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error ?? 'Failed to unassign agent'); + } + + const archetype = archetypes.find(a => a.id === archetypeId); + setAssignSuccess(`Unassigned ${archetype?.name ?? archetypeId}`); + setTimeout(() => setAssignSuccess(null), 2000); + } catch (err) { + // Revert on error + setLocalLabels(prev => [...prev, labelToRemove]); + setAssignError(err instanceof Error ? err.message : 'Failed to unassign agent'); + setTimeout(() => setAssignError(null), 3000); + } finally { + setIsAssigning(false); + } + }; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > - {/* Target handle for incoming edges (from the left) */} - {/* Main card body */}
- {/* Header: ID + priority + status badges */}
{id} -
- {/* "READY" badge for actionable nodes */} +
+ {assignedArchetypes.map((archetype) => ( + + {archetype.name} + + ))} {data.isActionable ? ( Ready ) : null} - {/* Status badge: IN PROGRESS, BLOCKED, DONE */} {data.status === 'in_progress' ? ( In Progress @@ -138,12 +237,10 @@ export function GraphNodeCard({ id, data, selected }: NodeProps
- {/* Title - strikethrough for closed tasks */}

{data.title}

- {/* Footer: show blocker names for blocked tasks, click hint for others */} {data.blockerTooltipLines.length > 0 ? (

Waiting on

@@ -159,9 +256,99 @@ export function GraphNodeCard({ id, data, selected }: NodeProps ) : null} + + {!isClosed && archetypes.length > 0 ? ( +
+ {assignSuccess ? ( +
+ {assignSuccess} +
+ ) : null} + {assignError ? ( +
+ {assignError} +
+ ) : null} + {assignedArchetypes.length > 0 ? ( +
+ {assignedArchetypes.map((archetype) => ( +
+ {archetype.name} + +
+ ))} +
+ ) : null} + + + + + + + {archetypes.map((archetype) => { + const isAssigned = assignedArchetypes.some(a => a.id === archetype.id); + return ( + handleAssignAgent(archetype.id)} + className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[11px] font-medium outline-none cursor-pointer transition-colors ${ + isAssigned + ? 'opacity-50 cursor-not-allowed' + : 'text-text-strong hover:bg-white/10 focus:bg-white/10' + }`} + > + + {archetype.name} + {isAssigned && ( + Assigned + )} + + ); + })} + + + +
+ ) : null}
- {/* Tooltip: shown on hover with 300ms CSS delay */} {hovered ? (
@@ -192,7 +379,6 @@ export function GraphNodeCard({ id, data, selected }: NodeProps ) : null} - {/* Source handle for outgoing edges (to the right) */}
); diff --git a/tests/components/graph/graph-node-assign.test.tsx b/tests/components/graph/graph-node-assign.test.tsx new file mode 100644 index 0000000..8267796 --- /dev/null +++ b/tests/components/graph/graph-node-assign.test.tsx @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs/promises'; +import path from 'path'; + +// Test that GraphNodeCard has Assign button for assignable tasks +test('GraphNodeCard checks for assignable status (open, blocked, ready)', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8'); + assert.ok( + fileContent.includes("'open'") && + (fileContent.includes("'blocked'") || fileContent.includes("status === 'blocked'")), + 'GraphNodeCard should check for open/blocked status for assign button' + ); +}); + +// Test that Assign button is NOT shown for closed tasks +test('GraphNodeCard excludes closed tasks from assign button', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8'); + assert.ok( + !fileContent.includes("status === 'closed' &&") || + fileContent.includes("status !== 'closed'"), + 'Assign button should not show for closed tasks' + ); +}); + +// Test that GraphNodeCard shows assigned archetype from labels +test('GraphNodeCard parses agent: label to show assigned archetype', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8'); + assert.ok( + fileContent.includes('agent:') || + fileContent.includes('getArchetypeFromLabels') || + fileContent.includes('data.labels'), + 'GraphNodeCard should check for agent: labels' + ); +}); + +// Test that Radix dropdown-menu is imported +test('GraphNodeCard imports Radix dropdown-menu for archetype selection', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8'); + assert.ok( + fileContent.includes('@radix-ui/react-dropdown-menu') || + fileContent.includes('DropdownMenu'), + 'GraphNodeCard should import Radix dropdown-menu' + ); +}); + +// Test that archetypes are passed to node and used +test('GraphNodeCard receives and uses archetypes for dropdown', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8'); + assert.ok( + fileContent.includes('archetypes') || + fileContent.includes('AgentArchetype'), + 'GraphNodeCard should reference archetypes' + ); +}); + +// Test that onAssign callback or similar is supported +test('GraphNodeCard supports assignment callback', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8'); + assert.ok( + fileContent.includes('onAssign') || + fileContent.includes('Assign') || + fileContent.includes('/api/swarm/prep'), + 'GraphNodeCard should support assignment action' + ); +});