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)
This commit is contained in:
zenchantlive 2026-02-24 16:14:56 -08:00
parent 164b26e570
commit 512a836db4
3 changed files with 297 additions and 19 deletions

View file

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

View file

@ -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<Node<GraphNodeData>>) {
// Track hover state for tooltip visibility
const [hovered, setHovered] = useState(false);
const [isAssigning, setIsAssigning] = useState(false);
const [assignError, setAssignError] = useState<string | null>(null);
const [assignSuccess, setAssignSuccess] = useState<string | null>(null);
// Local state for labels with optimistic updates
const [localLabels, setLocalLabels] = useState<string[]>(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 (
<div
@ -81,45 +177,48 @@ export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeDa
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 */}
<div className="flex items-center gap-1.5 flex-wrap">
{assignedArchetypes.map((archetype) => (
<span
key={archetype.id}
className="rounded-md px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-white ring-1"
style={{
backgroundColor: `${archetype.color}20`,
borderColor: `${archetype.color}40`,
color: archetype.color,
}}
>
{archetype.name}
</span>
))}
{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
@ -138,12 +237,10 @@ export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeDa
</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>
@ -159,9 +256,99 @@ export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeDa
) : null}
</div>
) : null}
{!isClosed && archetypes.length > 0 ? (
<div className="mt-2 border-t border-white/5 pt-2">
{assignSuccess ? (
<div className="text-[9px] text-emerald-400 font-medium mb-1.5">
{assignSuccess}
</div>
) : null}
{assignError ? (
<div className="text-[9px] text-rose-400 font-medium mb-1.5">
{assignError}
</div>
) : null}
{assignedArchetypes.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1">
{assignedArchetypes.map((archetype) => (
<div
key={archetype.id}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[8px] font-medium ring-1"
style={{
backgroundColor: `${archetype.color}15`,
borderColor: `${archetype.color}30`,
color: archetype.color,
}}
>
<span>{archetype.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleUnassignAgent(archetype.id);
}}
disabled={isAssigning}
className="hover:opacity-70 transition-opacity"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
))}
</div>
) : null}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
disabled={isAssigning}
className="flex items-center gap-1.5 w-full rounded-md bg-white/5 hover:bg-white/10 px-2 py-1.5 text-[9px] font-medium text-text-muted/80 transition-colors disabled:opacity-50"
>
{isAssigning ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<UserPlus className="h-3 w-3" />
)}
<span>Assign Agent</span>
<ChevronDown className="h-3 w-3 ml-auto" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[180px] rounded-lg border border-white/10 bg-[#0d0f14]/95 p-1 shadow-[0_12px_32px_rgba(0,0,0,0.6)] backdrop-blur-lg z-50"
sideOffset={4}
>
{archetypes.map((archetype) => {
const isAssigned = assignedArchetypes.some(a => a.id === archetype.id);
return (
<DropdownMenu.Item
key={archetype.id}
disabled={isAssigned || isAssigning}
onClick={() => 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'
}`}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: archetype.color }}
/>
<span>{archetype.name}</span>
{isAssigned && (
<span className="ml-auto text-[9px] text-text-muted/60">Assigned</span>
)}
</DropdownMenu.Item>
);
})}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</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">
@ -192,7 +379,6 @@ export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeDa
</div>
) : null}
{/* Source handle for outgoing edges (to the right) */}
<Handle type="source" position={Position.Right} className="!opacity-0" />
</div>
);

View file

@ -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'
);
});