feat: Council - agent teams management
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Agent, AgentTeam, defaultTeams } from "@/lib/council/types";
|
||||
|
||||
const STORAGE_KEY = "horus:council";
|
||||
|
||||
export default function Council() {
|
||||
const [teams, setTeams] = useState<AgentTeam[]>(defaultTeams);
|
||||
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
|
||||
const [runningTask, setRunningTask] = useState<string>("");
|
||||
const [agentOutputs, setAgentOutputs] = useState<Record<string, string>>({});
|
||||
|
||||
// Load from localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
setTeams(JSON.parse(saved));
|
||||
} catch {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save changes
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(teams));
|
||||
}, [teams]);
|
||||
|
||||
const getStatusColor = (status: Agent["status"]) => {
|
||||
switch (status) {
|
||||
case "working": return "text-yellow-400 bg-yellow-400/20";
|
||||
case "completed": return "text-green-400 bg-green-400/20";
|
||||
case "error": return "text-red-400 bg-red-400/20";
|
||||
default: return "text-white/50 bg-white/10";
|
||||
}
|
||||
};
|
||||
|
||||
const spawnAgent = async (agent: Agent, task: string) => {
|
||||
if (!task.trim()) return;
|
||||
|
||||
// Update agent status
|
||||
setTeams(prev => prev.map(team => ({
|
||||
...team,
|
||||
agents: team.agents.map(a => a.id === agent.id ? { ...a, status: "working" as const, currentTask: task } : a)
|
||||
})));
|
||||
setRunningTask("");
|
||||
|
||||
// Simulate agent work (in reality this would call actual agents)
|
||||
const output = `[🤖 ${agent.name}] Starting task: "${task}"\n\n`;
|
||||
setAgentOutputs(prev => ({ ...prev, [agent.id]: output + "⏳ Processing...\n" }));
|
||||
|
||||
// Simulate completion after delay
|
||||
setTimeout(() => {
|
||||
const result = output + `✅ Task completed: ${task}\n\n💡 Result: Task processed successfully.`;
|
||||
setAgentOutputs(prev => ({ ...prev, [agent.id]: result }));
|
||||
|
||||
setTeams(prev => prev.map(team => ({
|
||||
...team,
|
||||
agents: team.agents.map(a => a.id === agent.id ? {
|
||||
...a,
|
||||
status: "completed" as const,
|
||||
lastRun: new Date().toISOString(),
|
||||
currentTask: undefined
|
||||
} : a)
|
||||
})));
|
||||
}, 3000 + Math.random() * 2000);
|
||||
};
|
||||
|
||||
const currentTeam = teams.find(t => t.id === selectedTeam);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
🏛️ Council
|
||||
</h2>
|
||||
<p className="text-white/60 mt-1">
|
||||
Manage AI agent teams for each project. I'm the boss — I delegate tasks and oversee execution.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Team Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{teams.map((team) => (
|
||||
<button
|
||||
key={team.id}
|
||||
onClick={() => setSelectedTeam(team.id)}
|
||||
className={`p-4 rounded-xl border text-left transition ${
|
||||
selectedTeam === team.id
|
||||
? "border-brand-pink bg-brand-pink/10"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-2xl">{team.icon}</span>
|
||||
<div>
|
||||
<p className="font-semibold">{team.name}</p>
|
||||
<p className="text-xs text-white/50">{team.agents.length} agents</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-white/60">{team.description}</p>
|
||||
<div className="mt-3 flex gap-1">
|
||||
{team.agents.slice(0, 4).map(a => (
|
||||
<span key={a.id} className={`w-2 h-2 rounded-full ${getStatusColor(a.status).split(' ')[1].replace('/', '-')}`} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selected Team Detail */}
|
||||
{currentTeam && (
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{currentTeam.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-semibold">{currentTeam.name}</h3>
|
||||
<p className="text-sm text-white/50">{currentTeam.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setSelectedTeam(null)} className="text-white/50 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Agents */}
|
||||
<div className="space-y-3">
|
||||
{currentTeam.agents.map((agent) => (
|
||||
<div key={agent.id} className="p-4 rounded-lg border border-white/10 bg-white/5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getStatusColor(agent.status)}`}>
|
||||
{agent.status.toUpperCase()}
|
||||
</span>
|
||||
<span className="font-medium">{agent.name}</span>
|
||||
<span className="text-white/50">— {agent.role}</span>
|
||||
</div>
|
||||
{agent.lastRun && (
|
||||
<span className="text-xs text-white/40">Last: {new Date(agent.lastRun).toLocaleTimeString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-white/60 mb-3">{agent.description}</p>
|
||||
|
||||
{/* Task Input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={runningTask}
|
||||
onChange={(e) => setRunningTask(e.target.value)}
|
||||
placeholder={`Ask ${agent.name} to...`}
|
||||
className="flex-1 bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-sm placeholder:text-white/40 focus:outline-none focus:border-brand-pink"
|
||||
onKeyDown={(e) => e.key === "Enter" && spawnAgent(agent, runningTask)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => spawnAgent(agent, runningTask)}
|
||||
disabled={agent.status === "working" || !runningTask.trim()}
|
||||
className="px-4 py-2 bg-brand-pink rounded-lg text-sm font-medium disabled:opacity-50 hover:bg-[#ff7bc0] transition"
|
||||
>
|
||||
🚀 Run
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
{agentOutputs[agent.id] && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-black/30 text-sm font-mono text-white/80 whitespace-pre-wrap max-h-40 overflow-y-auto">
|
||||
{agentOutputs[agent.id]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Horus Boss Note */}
|
||||
<div className="p-4 rounded-xl border border-brand-pink/30 bg-brand-pink/10">
|
||||
<p className="text-sm text-brand-pink">
|
||||
👁️ <strong>Note:</strong> I coordinate these agents. They run as sub-tasks within my sessions.
|
||||
In production, they'd be separate AI processes. Currently simulating — real agent spawning requires
|
||||
the OpenClaw sub-agent system to be configured.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useMissionControl } from "@/lib/mission-control/store";
|
||||
import { TaskStatus } from "@/lib/mission-control/types";
|
||||
import VoiceChat from "./VoiceChat";
|
||||
import AIManagement from "@/components/ai-management/AIManagement";
|
||||
import Council from "@/components/council/Council";
|
||||
|
||||
interface SidebarItem {
|
||||
id: string;
|
||||
@@ -33,7 +34,8 @@ const sidebarCategories: SidebarCategory[] = [
|
||||
{ id: "voice", name: "Voice Chat", icon: "🎤", category: "chat" },
|
||||
]},
|
||||
{ id: "council", name: "Council", icon: "🏛️", items: [
|
||||
{ id: "ai-settings", name: "AI Settings", icon: "🤖", category: "council" },
|
||||
{ id: "teams", name: "Agent Teams", icon: "👥", category: "council" },
|
||||
{ id: "ai-settings", name: "AI Settings", icon: "🤖", category: "council-settings" },
|
||||
]},
|
||||
{ id: "calendar", name: "Calendar", icon: "📅", items: [
|
||||
{ id: "brief", name: "Morning Brief", icon: "☀️", category: "calendar" },
|
||||
@@ -61,7 +63,6 @@ export default function MissionControlDashboard() {
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const [newTaskProject, setNewTaskProject] = useState("sitemente");
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
|
||||
@@ -84,27 +85,18 @@ export default function MissionControlDashboard() {
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Find selected item
|
||||
let selectedCategory = sidebarCategories.find(c => c.items.some(i => i.id === selectedItem));
|
||||
let currentItem = selectedCategory?.items.find(i => i.id === selectedItem);
|
||||
|
||||
const projectTasks = currentItem?.category === "projects" ? getTasksByProject(selectedItem as any) : tasks;
|
||||
const filteredTasks = filter === "all" ? projectTasks : projectTasks.filter((t) => t.status === filter);
|
||||
const searchedTasks = searchQuery
|
||||
? filteredTasks.filter(t => t.title.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: filteredTasks;
|
||||
const searchedTasks = searchQuery ? filteredTasks.filter(t => t.title.toLowerCase().includes(searchQuery.toLowerCase())) : filteredTasks;
|
||||
const progress = currentItem?.category === "projects" ? getProjectProgress(selectedItem as any) : 0;
|
||||
|
||||
const toggleCategory = (catId: string) => {
|
||||
setExpandedCategories(prev => prev.includes(catId) ? prev.filter(id => id !== catId) : [...prev, catId]);
|
||||
};
|
||||
|
||||
const toggleCategory = (catId: string) => setExpandedCategories(prev => prev.includes(catId) ? prev.filter(id => id !== catId) : [...prev, catId]);
|
||||
const collapseAll = () => setExpandedCategories([]);
|
||||
|
||||
// Today's focus
|
||||
const todayTasks = projectTasks.filter(t => t.priority === "critical" || t.status === "in_progress").slice(0, 3);
|
||||
|
||||
// Export function
|
||||
const exportTasks = () => {
|
||||
const data = JSON.stringify(projectTasks, null, 2);
|
||||
const blob = new Blob([data], { type: "application/json" });
|
||||
@@ -115,7 +107,6 @@ export default function MissionControlDashboard() {
|
||||
a.click();
|
||||
};
|
||||
|
||||
// Add task
|
||||
const handleAddTask = () => {
|
||||
if (!newTaskTitle.trim()) return;
|
||||
addTask({ title: newTaskTitle, description: "", status: "todo", priority: "medium", project: newTaskProject as any });
|
||||
@@ -123,8 +114,8 @@ export default function MissionControlDashboard() {
|
||||
setNewTaskTitle("");
|
||||
};
|
||||
|
||||
// Render functions
|
||||
const renderSidebar = () => (
|
||||
return (
|
||||
<div className="min-h-screen bg-[#1a1625] text-white flex">
|
||||
<aside className="w-64 border-r border-white/10 bg-[#1a1625] p-4">
|
||||
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-white/10">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-pink flex items-center justify-center text-xl">👁️</div>
|
||||
@@ -155,9 +146,8 @@ export default function MissionControlDashboard() {
|
||||
<button onClick={collapseAll} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-white/60 hover:bg-white/5 hover:text-white transition">⊖ Collapse All</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
const renderHeader = () => (
|
||||
<main className="flex-1 p-6 overflow-y-auto">
|
||||
<header className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => { setSelectedItem("sitemente"); setExpandedCategories(["projects"]); }} className="w-10 h-10 rounded-xl bg-brand-pink flex items-center justify-center text-xl hover:bg-[#ff7bc0] transition">👁️</button>
|
||||
@@ -175,13 +165,14 @@ export default function MissionControlDashboard() {
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
const category = currentItem?.category;
|
||||
if (category === "chat") return <div className="rounded-xl border border-white/10 bg-white/5 p-6"><VoiceChat /></div>;
|
||||
if (category === "council") return <div className="rounded-xl border border-white/10 bg-white/5 p-6"><AIManagement /></div>;
|
||||
if (category === "calendar") return (
|
||||
{currentItem?.category === "chat" && <div className="rounded-xl border border-white/10 bg-white/5 p-6"><VoiceChat /></div>}
|
||||
|
||||
{currentItem?.category === "council" && currentItem?.id === "teams" && <div className="rounded-xl border border-white/10 bg-white/5 p-6"><Council /></div>}
|
||||
|
||||
{currentItem?.category === "council-settings" && <div className="rounded-xl border border-white/10 bg-white/5 p-6"><AIManagement /></div>}
|
||||
|
||||
{currentItem?.category === "calendar" && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">📅 Morning Brief</h3>
|
||||
@@ -190,18 +181,17 @@ export default function MissionControlDashboard() {
|
||||
</div>
|
||||
<VoiceChat />
|
||||
</div>
|
||||
);
|
||||
if (category === "memory") return (
|
||||
)}
|
||||
|
||||
{currentItem?.category === "memory" && (
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">🧠 Memory & Logs</h3>
|
||||
<p className="text-white/60 mb-4">Session history and daily logs</p>
|
||||
<div className="space-y-2 text-sm text-white/70"><p>Memory is stored in:</p><ul className="text-white/50 space-y-1"><li>• localStorage (browser)</li><li>• GitHub repo (daily commits)</li><li>• MEMORY.md (curated)</li></ul></div>
|
||||
</div>
|
||||
);
|
||||
return renderTasksView();
|
||||
};
|
||||
)}
|
||||
|
||||
const renderTasksView = () => (
|
||||
{currentItem?.category === "projects" && (
|
||||
<>
|
||||
{todayTasks.length > 0 && (
|
||||
<div className="mb-6 p-4 rounded-xl border border-brand-pink/30 bg-brand-pink/10">
|
||||
@@ -227,7 +217,9 @@ export default function MissionControlDashboard() {
|
||||
<input type="text" value={newTaskTitle} onChange={(e) => setNewTaskTitle(e.target.value)} placeholder="New task title..." className="w-full bg-white/10 border border-white/20 rounded-lg px-4 py-2 text-sm placeholder:text-white/40 focus:outline-none focus:border-brand-pink mb-3" autoFocus onKeyDown={(e) => e.key === "Enter" && handleAddTask()} />
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={newTaskProject} onChange={(e) => setNewTaskProject(e.target.value)} className="bg-white/10 border border-white/20 rounded-lg px-3 py-1.5 text-sm">
|
||||
<option value="sitemente">SiteMente</option><option value="holacompi">HolaCompi</option><option value="infrastructure">Infrastructure</option>
|
||||
<option value="sitemente">SiteMente</option>
|
||||
<option value="holacompi">HolaCompi</option>
|
||||
<option value="infrastructure">Infrastructure</option>
|
||||
</select>
|
||||
<button onClick={handleAddTask} className="px-4 py-1.5 bg-brand-pink rounded-lg text-sm font-medium">Add Task</button>
|
||||
<button onClick={() => setShowAddTask(false)} className="px-3 py-1.5 text-sm text-white/60 hover:text-white">Cancel</button>
|
||||
@@ -249,14 +241,12 @@ export default function MissionControlDashboard() {
|
||||
<div className="rounded-xl border border-white/10 bg-white/5">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">{currentItem?.name || "Tasks"}{searchQuery && <span className="ml-2 text-white/50 text-sm">({searchedTasks.length} results)</span>}</h2>
|
||||
<h2 className="font-semibold">{currentItem?.name}{searchQuery && <span className="ml-2 text-white/50 text-sm">({searchedTasks.length} results)</span>}</h2>
|
||||
<p className="text-sm text-white/50">{filteredTasks.filter((t) => t.status === "done").length} / {filteredTasks.length} done</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5 max-h-[500px] overflow-y-auto">
|
||||
{searchedTasks.map((task) => {
|
||||
const config = statusConfig[task.status];
|
||||
return (
|
||||
{searchedTasks.map((task) => (
|
||||
<div key={task.id} className={`flex items-center gap-3 px-4 py-3 transition hover:bg-white/5 ${task.status === "done" ? "opacity-50" : ""}`}>
|
||||
<button onClick={() => toggleTask(task.id)} className={`flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition ${task.status === "done" ? "border-green-500 bg-green-500 text-white" : "border-white/30 hover:border-white/50"}`}>
|
||||
{task.status === "done" && <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>}
|
||||
@@ -267,20 +257,12 @@ export default function MissionControlDashboard() {
|
||||
<option value="todo">To Do</option><option value="in_progress">In Progress</option><option value="done">Done</option><option value="blocked">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
{searchedTasks.length === 0 && <div className="px-4 py-8 text-center text-white/50 text-sm">No tasks match the current filter.</div>}
|
||||
{searchedTasks.length === 0 && <div className="px-4 py-8 text-center text-white/50 text-sm">No tasks match.</div>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#1a1625] text-white flex">
|
||||
{renderSidebar()}
|
||||
<main className="flex-1 p-6 overflow-y-auto">
|
||||
{renderHeader()}
|
||||
{renderContent()}
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Agent Types for Council
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
description: string;
|
||||
status: "idle" | "working" | "completed" | "error";
|
||||
project: string;
|
||||
lastRun?: string;
|
||||
currentTask?: string;
|
||||
}
|
||||
|
||||
export interface AgentTeam {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
export const defaultTeams: AgentTeam[] = [
|
||||
{
|
||||
id: "sitemente-squad",
|
||||
name: "SiteMente Squad",
|
||||
icon: "🌐",
|
||||
description: "Building and growing SiteMente B2B platform",
|
||||
agents: [
|
||||
{ id: "sm-architect", name: "Architect", role: "PM & Architecture", description: "Planning, architecture, task breakdown", status: "idle", project: "sitemente" },
|
||||
{ id: "sm-frontend", name: "Frontend Dev", role: "UI/UX Implementation", description: "React, Next.js, components", status: "idle", project: "sitemente" },
|
||||
{ id: "sm-ai", name: "AI Engineer", role: "LLM & Voice Integration", description: "Gemini, voice agents, widgets", status: "idle", project: "sitemente" },
|
||||
{ id: "sm-seo", name: "SEO Specialist", role: "Content & Copy", description: "SEO, content, marketing copy", status: "idle", project: "sitemente" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "holacompi-squad",
|
||||
name: "HolaCompi Squad",
|
||||
icon: "🤝",
|
||||
description: "Consumer AI ally (paused until revenue)",
|
||||
agents: [
|
||||
{ id: "hc-product", name: "Product Manager", role: "Flows & UX", description: "User flows, product decisions", status: "idle", project: "holacompi" },
|
||||
{ id: "hc telephony", name: "Telephony Engineer", role: "Voice & Telco", description: "Telnyx, Vapi, phone lines", status: "idle", project: "holacompi" },
|
||||
{ id: "hc-voice", name: "Voice UX", role: "Conversation Design", description: "Dialogues, prompts, voice UX", status: "idle", project: "holacompi" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "infrastructure-team",
|
||||
name: "Infrastructure",
|
||||
icon: "🔧",
|
||||
description: "Security, backups, and system ops",
|
||||
agents: [
|
||||
{ id: "infra-sec", name: "Security Lead", role: "Hardening & Audits", description: "UFW, SSH, security audits", status: "idle", project: "infrastructure" },
|
||||
{ id: "infra-backup", name: "Backup Manager", role: "Backup & Recovery", description: "Auto backups, cloud sync", status: "idle", project: "infrastructure" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const agentRoles = [
|
||||
{ id: "architect", name: "Architect / PM", icon: "📋" },
|
||||
{ id: "frontend", name: "Frontend Dev", icon: "🎨" },
|
||||
{ id: "backend", name: "Backend Dev", icon: "⚙️" },
|
||||
{ id: "ai-engineer", name: "AI Engineer", icon: "🤖" },
|
||||
{ id: "seo", name: "SEO / Copy", icon: "📝" },
|
||||
{ id: "product", name: "Product Manager", icon: "📦" },
|
||||
{ id: "security", name: "Security", icon: "🛡️" },
|
||||
{ id: "devops", name: "DevOps", icon: "🚀" },
|
||||
];
|
||||
Reference in New Issue
Block a user