HuggingClaw-MissionControl / src /components /panels /agent-squad-panel.tsx
nyk
feat: full i18n — 1752 keys across 10 languages, all panels translated (#326)
b180108 unverified
Raw
History Blame Contribute Delete
22 kB
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Loader } from '@/components/ui/loader'
import { createClientLogger } from '@/lib/client-logger'
const log = createClientLogger('AgentSquadPanel')
interface Agent {
id: number
name: string
role: string
session_key?: string
soul_content?: string
status: 'offline' | 'idle' | 'busy' | 'error'
last_seen?: number
last_activity?: string
created_at: number
updated_at: number
config?: any
taskStats?: {
total: number
assigned: number
in_progress: number
completed: number
}
}
const statusColors: Record<string, string> = {
offline: 'bg-gray-500',
idle: 'bg-green-500',
busy: 'bg-yellow-500',
error: 'bg-red-500',
}
const statusIcons: Record<string, string> = {
offline: '⚫',
idle: '🟢',
busy: '🟡',
error: '🔴',
}
export function AgentSquadPanel() {
const t = useTranslations('agentSquad')
const [agents, setAgents] = useState<Agent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [autoRefresh, setAutoRefresh] = useState(true)
// Fetch agents
const fetchAgents = useCallback(async () => {
try {
setError(null)
if (agents.length === 0) setLoading(true)
const response = await fetch('/api/agents')
if (!response.ok) throw new Error(t('failedToFetch'))
const data = await response.json()
setAgents(data.agents || [])
} catch (err) {
setError(err instanceof Error ? err.message : t('errorOccurred'))
} finally {
setLoading(false)
}
}, [agents.length])
// Initial load
useEffect(() => {
fetchAgents()
}, [fetchAgents])
// Auto-refresh
useEffect(() => {
if (!autoRefresh) return
const interval = setInterval(fetchAgents, 10000) // Every 10 seconds
return () => clearInterval(interval)
}, [autoRefresh, fetchAgents])
// Update agent status
const updateAgentStatus = async (agentName: string, status: Agent['status'], activity?: string) => {
try {
const response = await fetch('/api/agents', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: agentName,
status,
last_activity: activity || `Status changed to ${status}`
})
})
if (!response.ok) throw new Error(t('failedToUpdateStatus'))
// Update local state
setAgents(prev => prev.map(agent =>
agent.name === agentName
? {
...agent,
status,
last_activity: activity || `Status changed to ${status}`,
last_seen: Math.floor(Date.now() / 1000),
updated_at: Math.floor(Date.now() / 1000)
}
: agent
))
} catch (error) {
log.error('Failed to update agent status:', error)
setError(t('failedToUpdateStatus'))
}
}
// Format last seen time
const formatLastSeen = (timestamp?: number) => {
if (!timestamp) return t('never')
const now = Date.now()
const diffMs = now - (timestamp * 1000)
const diffMinutes = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMinutes < 1) return t('justNow')
if (diffMinutes < 60) return t('minutesAgo', { count: diffMinutes })
if (diffHours < 24) return t('hoursAgo', { count: diffHours })
if (diffDays < 7) return t('daysAgo', { count: diffDays })
return new Date(timestamp * 1000).toLocaleDateString()
}
// Get status distribution for summary
const statusCounts = agents.reduce((acc, agent) => {
acc[agent.status] = (acc[agent.status] || 0) + 1
return acc
}, {} as Record<string, number>)
if (loading && agents.length === 0) {
return <Loader variant="panel" label={t('loadingAgents')} />
}
return (
<div className="h-full flex flex-col bg-gray-900">
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-gray-700">
<div className="flex items-center gap-4">
<h2 className="text-xl font-bold text-white">{t('title')}</h2>
{/* Status Summary */}
<div className="flex gap-2 text-sm">
{Object.entries(statusCounts).map(([status, count]) => (
<div key={status} className="flex items-center gap-1">
<div className={`w-2 h-2 rounded-full ${statusColors[status]}`}></div>
<span className="text-gray-400">{count}</span>
</div>
))}
</div>
</div>
<div className="flex gap-2">
<Button
onClick={() => setAutoRefresh(!autoRefresh)}
variant={autoRefresh ? 'success' : 'secondary'}
size="sm"
>
{autoRefresh ? t('live') : t('manual')}
</Button>
<Button
onClick={() => setShowCreateModal(true)}
>
{t('addAgent')}
</Button>
<Button
onClick={fetchAgents}
variant="secondary"
>
{t('refresh')}
</Button>
</div>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 p-3 m-4 rounded">
{error}
<Button
onClick={() => setError(null)}
variant="ghost"
size="icon-sm"
className="float-right text-red-300 hover:text-red-100"
>
×
</Button>
</div>
)}
{/* Agent Grid */}
<div className="flex-1 p-4 overflow-y-auto">
{agents.length === 0 ? (
<div className="text-center text-gray-500 py-8">
<div className="text-4xl mb-2">🤖</div>
<p>{t('noAgents')}</p>
<p className="text-sm">{t('addFirstAgent')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{agents.map(agent => (
<div
key={agent.id}
className="bg-gray-800 rounded-lg p-4 border-l-4 border-gray-600 hover:bg-gray-750 transition-colors cursor-pointer"
onClick={() => setSelectedAgent(agent)}
>
{/* Agent Header */}
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-white text-lg">{agent.name}</h3>
<p className="text-gray-400 text-sm">{agent.role}</p>
</div>
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${statusColors[agent.status]} animate-pulse`}></div>
<span className="text-xs text-gray-400">{agent.status}</span>
</div>
</div>
{/* Session Info */}
{agent.session_key && (
<div className="text-xs text-gray-400 mb-2">
<span className="font-medium">{t('session')}:</span> {agent.session_key}
</div>
)}
{/* Task Stats */}
{agent.taskStats && (
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="bg-gray-700/50 rounded p-2 text-center">
<div className="text-lg font-semibold text-white">{agent.taskStats.total}</div>
<div className="text-xs text-gray-400">{t('totalTasks')}</div>
</div>
<div className="bg-gray-700/50 rounded p-2 text-center">
<div className="text-lg font-semibold text-yellow-400">{agent.taskStats.in_progress}</div>
<div className="text-xs text-gray-400">{t('inProgress')}</div>
</div>
</div>
)}
{/* Last Activity */}
<div className="text-xs text-gray-400 mb-3">
<div>
<span className="font-medium">{t('lastSeen')}:</span> {formatLastSeen(agent.last_seen)}
</div>
{agent.last_activity && (
<div className="mt-1 truncate" title={agent.last_activity}>
<span className="font-medium">{t('activity')}:</span> {agent.last_activity}
</div>
)}
</div>
{/* Quick Actions */}
<div className="flex gap-1">
<Button
onClick={(e) => {
e.stopPropagation()
updateAgentStatus(agent.name, 'idle', 'Manually activated')
}}
disabled={agent.status === 'idle'}
variant="success"
size="xs"
className="flex-1"
>
{t('wake')}
</Button>
<Button
onClick={(e) => {
e.stopPropagation()
updateAgentStatus(agent.name, 'busy', 'Manually set to busy')
}}
disabled={agent.status === 'busy'}
size="xs"
className="flex-1 bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 hover:bg-yellow-500/30"
>
{t('busy')}
</Button>
<Button
onClick={(e) => {
e.stopPropagation()
updateAgentStatus(agent.name, 'offline', 'Manually set offline')
}}
disabled={agent.status === 'offline'}
variant="secondary"
size="xs"
className="flex-1"
>
{t('sleep')}
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* Agent Detail Modal */}
{selectedAgent && (
<AgentDetailModal
agent={selectedAgent}
onClose={() => setSelectedAgent(null)}
onUpdate={fetchAgents}
onStatusUpdate={updateAgentStatus}
/>
)}
{/* Create Agent Modal */}
{showCreateModal && (
<CreateAgentModal
onClose={() => setShowCreateModal(false)}
onCreated={fetchAgents}
/>
)}
</div>
)
}
// Agent Detail Modal
function AgentDetailModal({
agent,
onClose,
onUpdate,
onStatusUpdate
}: {
agent: Agent
onClose: () => void
onUpdate: () => void
onStatusUpdate: (name: string, status: Agent['status'], activity?: string) => Promise<void>
}) {
const t = useTranslations('agentSquad')
const [editing, setEditing] = useState(false)
const [formData, setFormData] = useState({
role: agent.role,
session_key: agent.session_key || '',
soul_content: agent.soul_content || '',
})
const handleSave = async () => {
try {
const response = await fetch('/api/agents', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: agent.name,
...formData
})
})
if (!response.ok) throw new Error(t('failedToUpdate'))
setEditing(false)
onUpdate()
} catch (error) {
log.error('Failed to update agent:', error)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-white">{agent.name}</h3>
<p className="text-gray-400">{agent.role}</p>
</div>
<div className="flex items-center gap-3">
<div className={`w-4 h-4 rounded-full ${statusColors[agent.status]}`}></div>
<span className="text-white">{agent.status}</span>
<Button onClick={onClose} variant="ghost" size="icon-sm" className="text-2xl">×</Button>
</div>
</div>
{/* Status Controls */}
<div className="mb-6 p-4 bg-gray-700/50 rounded-lg">
<h4 className="text-sm font-medium text-white mb-2">{t('statusControl')}</h4>
<div className="flex gap-2">
{(['idle', 'busy', 'offline'] as const).map(status => (
<Button
key={status}
onClick={() => onStatusUpdate(agent.name, status)}
variant={agent.status === status ? 'default' : 'secondary'}
size="sm"
>
{statusIcons[status]} {status}
</Button>
))}
</div>
</div>
{/* Agent Details */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">{t('role')}</label>
{editing ? (
<input
type="text"
value={formData.role}
onChange={(e) => setFormData(prev => ({ ...prev, role: e.target.value }))}
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<p className="text-white">{agent.role}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">{t('sessionKey')}</label>
{editing ? (
<input
type="text"
value={formData.session_key}
onChange={(e) => setFormData(prev => ({ ...prev, session_key: e.target.value }))}
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<p className="text-white font-mono">{agent.session_key || t('notSet')}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">{t('soulContent')}</label>
{editing ? (
<textarea
value={formData.soul_content}
onChange={(e) => setFormData(prev => ({ ...prev, soul_content: e.target.value }))}
rows={4}
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('soulPlaceholder')}
/>
) : (
<p className="text-white whitespace-pre-wrap">{agent.soul_content || t('notSet')}</p>
)}
</div>
{/* Task Statistics */}
{agent.taskStats && (
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">{t('taskStatistics')}</label>
<div className="grid grid-cols-4 gap-2">
<div className="bg-gray-700/50 rounded p-3 text-center">
<div className="text-lg font-semibold text-white">{agent.taskStats.total}</div>
<div className="text-xs text-gray-400">{t('total')}</div>
</div>
<div className="bg-gray-700/50 rounded p-3 text-center">
<div className="text-lg font-semibold text-blue-400">{agent.taskStats.assigned}</div>
<div className="text-xs text-gray-400">{t('assigned')}</div>
</div>
<div className="bg-gray-700/50 rounded p-3 text-center">
<div className="text-lg font-semibold text-yellow-400">{agent.taskStats.in_progress}</div>
<div className="text-xs text-gray-400">{t('inProgress')}</div>
</div>
<div className="bg-gray-700/50 rounded p-3 text-center">
<div className="text-lg font-semibold text-green-400">{agent.taskStats.completed}</div>
<div className="text-xs text-gray-400">{t('done')}</div>
</div>
</div>
</div>
)}
{/* Timestamps */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-400">{t('created')}:</span>
<span className="text-white ml-2">{new Date(agent.created_at * 1000).toLocaleDateString()}</span>
</div>
<div>
<span className="text-gray-400">{t('lastUpdated')}:</span>
<span className="text-white ml-2">{new Date(agent.updated_at * 1000).toLocaleDateString()}</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 mt-6">
{editing ? (
<>
<Button
onClick={handleSave}
className="flex-1"
>
{t('saveChanges')}
</Button>
<Button
onClick={() => setEditing(false)}
variant="secondary"
className="flex-1"
>
{t('cancel')}
</Button>
</>
) : (
<Button
onClick={() => setEditing(true)}
className="flex-1"
>
{t('editAgent')}
</Button>
)}
</div>
</div>
</div>
</div>
)
}
// Create Agent Modal
function CreateAgentModal({
onClose,
onCreated
}: {
onClose: () => void
onCreated: () => void
}) {
const t = useTranslations('agentSquad')
const [formData, setFormData] = useState({
name: '',
role: '',
session_key: '',
soul_content: '',
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const response = await fetch('/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
if (!response.ok) throw new Error(t('failedToCreate'))
onCreated()
onClose()
} catch (error) {
log.error('Error creating agent:', error)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg max-w-md w-full">
<form onSubmit={handleSubmit} className="p-6">
<h3 className="text-xl font-bold text-white mb-4">{t('createNewAgent')}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">{t('name')}</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">{t('role')}</label>
<input
type="text"
value={formData.role}
onChange={(e) => setFormData(prev => ({ ...prev, role: e.target.value }))}
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('rolePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">{t('sessionKeyOptional')}</label>
<input
type="text"
value={formData.session_key}
onChange={(e) => setFormData(prev => ({ ...prev, session_key: e.target.value }))}
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('sessionKeyPlaceholder')}
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">{t('soulContentOptional')}</label>
<textarea
value={formData.soul_content}
onChange={(e) => setFormData(prev => ({ ...prev, soul_content: e.target.value }))}
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder={t('soulPlaceholder')}
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<Button
type="submit"
className="flex-1"
>
{t('createAgent')}
</Button>
<Button
type="button"
onClick={onClose}
variant="secondary"
className="flex-1"
>
{t('cancel')}
</Button>
</div>
</form>
</div>
</div>
)
}