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:
parent
164b26e570
commit
512a836db4
3 changed files with 297 additions and 19 deletions
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
66
tests/components/graph/graph-node-assign.test.tsx
Normal file
66
tests/components/graph/graph-node-assign.test.tsx
Normal 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'
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue