mission-control/src/components/panels/agent-detail-tabs.tsx

1597 lines
63 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
interface Agent {
id: number
name: string
role: string
session_key?: string
soul_content?: string
working_memory?: string
status: 'offline' | 'idle' | 'busy' | 'error'
last_seen?: number
last_activity?: string
created_at: number
updated_at: number
taskStats?: {
total: number
assigned: number
in_progress: number
completed: number
}
}
interface WorkItem {
type: string
count: number
items: any[]
}
interface HeartbeatResponse {
status: 'HEARTBEAT_OK' | 'WORK_ITEMS_FOUND'
agent: string
checked_at: number
work_items?: WorkItem[]
total_items?: number
message?: string
}
interface SoulTemplate {
name: string
description: string
size: 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: 'o',
busy: '~',
error: '!',
}
// Overview Tab Component
export function OverviewTab({
agent,
editing,
formData,
setFormData,
onSave,
onStatusUpdate,
onWakeAgent,
onEdit,
onCancel,
heartbeatData,
loadingHeartbeat,
onPerformHeartbeat
}: {
agent: Agent
editing: boolean
formData: any
setFormData: (data: any) => void
onSave: () => Promise<void>
onStatusUpdate: (name: string, status: Agent['status'], activity?: string) => Promise<void>
onWakeAgent: (name: string, sessionKey: string) => Promise<void>
onEdit: () => void
onCancel: () => void
heartbeatData: HeartbeatResponse | null
loadingHeartbeat: boolean
onPerformHeartbeat: () => Promise<void>
}) {
const [messageFrom, setMessageFrom] = useState('system')
const [directMessage, setDirectMessage] = useState('')
const [messageStatus, setMessageStatus] = useState<string | null>(null)
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault()
if (!directMessage.trim()) return
try {
setMessageStatus(null)
const response = await fetch('/api/agents/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
from: messageFrom || 'system',
to: agent.name,
message: directMessage
})
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Failed to send message')
setDirectMessage('')
setMessageStatus('Message sent')
} catch (error) {
setMessageStatus('Failed to send message')
}
}
return (
<div className="p-6 space-y-6">
{/* Status Controls */}
<div className="p-4 bg-surface-1/50 rounded-lg">
<h4 className="text-sm font-medium text-foreground mb-3">Status Control</h4>
<div className="flex gap-2 mb-3">
{(['idle', 'busy', 'offline'] as const).map(status => (
<button
key={status}
onClick={() => onStatusUpdate(agent.name, status)}
className={`px-3 py-1 text-sm rounded transition-smooth ${
agent.status === status
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:bg-surface-2'
}`}
>
{statusIcons[status]} {status}
</button>
))}
</div>
{/* Wake Agent Button */}
{agent.session_key && (
<button
onClick={() => onWakeAgent(agent.name, agent.session_key!)}
className="w-full bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 py-2 rounded-md hover:bg-cyan-500/30 transition-smooth"
>
Wake Agent via Session
</button>
)}
</div>
{/* Direct Message */}
<div className="p-4 bg-surface-1/50 rounded-lg">
<h4 className="text-sm font-medium text-foreground mb-3">Direct Message</h4>
{messageStatus && (
<div className="text-xs text-foreground/80 mb-2">{messageStatus}</div>
)}
<form onSubmit={handleSendMessage} className="space-y-2">
<div>
<label className="block text-xs text-muted-foreground mb-1">From</label>
<input
type="text"
value={messageFrom}
onChange={(e) => setMessageFrom(e.target.value)}
className="w-full bg-surface-1 text-foreground rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Message</label>
<textarea
value={directMessage}
onChange={(e) => setDirectMessage(e.target.value)}
className="w-full bg-surface-1 text-foreground rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
rows={3}
/>
</div>
<div className="flex justify-end">
<button
type="submit"
className="px-3 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-smooth text-xs"
>
Send Message
</button>
</div>
</form>
</div>
{/* Heartbeat Check */}
<div className="p-4 bg-surface-1/50 rounded-lg">
<div className="flex justify-between items-center mb-3">
<h4 className="text-sm font-medium text-foreground">Heartbeat Check</h4>
<button
onClick={onPerformHeartbeat}
disabled={loadingHeartbeat}
className="px-3 py-1 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-smooth"
>
{loadingHeartbeat ? 'Checking...' : 'Check Now'}
</button>
</div>
{heartbeatData && (
<div className="space-y-2">
<div className="text-sm text-foreground/80">
<strong>Status:</strong> {heartbeatData.status}
</div>
<div className="text-sm text-foreground/80">
<strong>Checked:</strong> {new Date(heartbeatData.checked_at * 1000).toLocaleString()}
</div>
{heartbeatData.work_items && heartbeatData.work_items.length > 0 && (
<div className="mt-3">
<div className="text-sm font-medium text-yellow-400 mb-2">
Work Items Found: {heartbeatData.total_items}
</div>
{heartbeatData.work_items.map((item, idx) => (
<div key={idx} className="text-sm text-foreground/80 ml-2">
{item.type}: {item.count} items
</div>
))}
</div>
)}
{heartbeatData.message && (
<div className="text-sm text-foreground/80">
<strong>Message:</strong> {heartbeatData.message}
</div>
)}
</div>
)}
</div>
{/* Agent Details */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">Role</label>
{editing ? (
<input
type="text"
value={formData.role}
onChange={(e) => setFormData((prev: any) => ({ ...prev, role: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
) : (
<p className="text-foreground">{agent.role}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">Session Key</label>
{editing ? (
<input
type="text"
value={formData.session_key}
onChange={(e) => setFormData((prev: any) => ({ ...prev, session_key: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
placeholder="OpenClaw session identifier"
/>
) : (
<div className="flex items-center gap-2">
<p className="text-foreground font-mono">{agent.session_key || 'Not set'}</p>
{agent.session_key && (
<div className="flex items-center gap-1 text-xs text-green-400">
<div className="w-2 h-2 rounded-full bg-green-400"></div>
<span>Bound</span>
</div>
)}
</div>
)}
</div>
{/* Task Statistics */}
{agent.taskStats && (
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">Task Statistics</label>
<div className="grid grid-cols-4 gap-2">
<div className="bg-surface-1/50 rounded p-3 text-center">
<div className="text-lg font-semibold text-foreground">{agent.taskStats.total}</div>
<div className="text-xs text-muted-foreground">Total</div>
</div>
<div className="bg-surface-1/50 rounded p-3 text-center">
<div className="text-lg font-semibold text-blue-400">{agent.taskStats.assigned}</div>
<div className="text-xs text-muted-foreground">Assigned</div>
</div>
<div className="bg-surface-1/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-muted-foreground">In Progress</div>
</div>
<div className="bg-surface-1/50 rounded p-3 text-center">
<div className="text-lg font-semibold text-green-400">{agent.taskStats.completed}</div>
<div className="text-xs text-muted-foreground">Done</div>
</div>
</div>
</div>
)}
{/* Timestamps */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Created:</span>
<span className="text-foreground ml-2">{new Date(agent.created_at * 1000).toLocaleDateString()}</span>
</div>
<div>
<span className="text-muted-foreground">Last Updated:</span>
<span className="text-foreground ml-2">{new Date(agent.updated_at * 1000).toLocaleDateString()}</span>
</div>
{agent.last_seen && (
<div className="col-span-2">
<span className="text-muted-foreground">Last Seen:</span>
<span className="text-foreground ml-2">{new Date(agent.last_seen * 1000).toLocaleString()}</span>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-3 mt-6">
{editing ? (
<>
<button
onClick={onSave}
className="flex-1 bg-primary text-primary-foreground py-2 rounded-md hover:bg-primary/90 transition-smooth"
>
Save Changes
</button>
<button
onClick={onCancel}
className="flex-1 bg-secondary text-muted-foreground py-2 rounded-md hover:bg-surface-2 transition-smooth"
>
Cancel
</button>
</>
) : (
<button
onClick={onEdit}
className="flex-1 bg-primary text-primary-foreground py-2 rounded-md hover:bg-primary/90 transition-smooth"
>
Edit Agent
</button>
)}
</div>
</div>
)
}
// SOUL Tab Component
export function SoulTab({
agent,
soulContent,
templates,
onSave
}: {
agent: Agent
soulContent: string
templates: SoulTemplate[]
onSave: (content: string, templateName?: string) => Promise<void>
}) {
const [editing, setEditing] = useState(false)
const [content, setContent] = useState(soulContent)
const [selectedTemplate, setSelectedTemplate] = useState<string>('')
useEffect(() => {
setContent(soulContent)
}, [soulContent])
const handleSave = async () => {
await onSave(content)
setEditing(false)
}
const handleLoadTemplate = async (templateName: string) => {
try {
const response = await fetch(`/api/agents/${agent.name}/soul?template=${templateName}`, {
method: 'PATCH'
})
if (response.ok) {
const data = await response.json()
setContent(data.content)
setSelectedTemplate(templateName)
}
} catch (error) {
console.error('Failed to load template:', error)
}
}
return (
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<h4 className="text-lg font-medium text-foreground">SOUL Configuration</h4>
<div className="flex gap-2">
{!editing && (
<button
onClick={() => setEditing(true)}
className="px-3 py-1 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-smooth"
>
Edit SOUL
</button>
)}
</div>
</div>
{/* Template Selector */}
{editing && templates.length > 0 && (
<div className="p-4 bg-surface-1/50 rounded-lg">
<h5 className="text-sm font-medium text-foreground mb-2">Load Template</h5>
<div className="flex gap-2">
<select
value={selectedTemplate}
onChange={(e) => setSelectedTemplate(e.target.value)}
className="flex-1 bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">Select a template...</option>
{templates.map(template => (
<option key={template.name} value={template.name}>
{template.description} ({template.size} chars)
</option>
))}
</select>
<button
onClick={() => selectedTemplate && handleLoadTemplate(selectedTemplate)}
disabled={!selectedTemplate}
className="px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-md hover:bg-green-500/30 disabled:opacity-50 transition-smooth"
>
Load
</button>
</div>
</div>
)}
{/* SOUL Editor */}
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
SOUL Content ({content.length} characters)
</label>
{editing ? (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={20}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50 font-mono text-sm"
placeholder="Define the agent's personality, instructions, and behavior patterns..."
/>
) : (
<div className="bg-surface-1/30 rounded p-4 max-h-96 overflow-y-auto">
{content ? (
<pre className="text-foreground whitespace-pre-wrap text-sm">{content}</pre>
) : (
<p className="text-muted-foreground italic">No SOUL content defined</p>
)}
</div>
)}
</div>
{/* Actions */}
{editing && (
<div className="flex gap-3">
<button
onClick={handleSave}
className="flex-1 bg-primary text-primary-foreground py-2 rounded-md hover:bg-primary/90 transition-smooth"
>
Save SOUL
</button>
<button
onClick={() => {
setEditing(false)
setContent(soulContent)
}}
className="flex-1 bg-secondary text-muted-foreground py-2 rounded-md hover:bg-surface-2 transition-smooth"
>
Cancel
</button>
</div>
)}
</div>
)
}
// Memory Tab Component
export function MemoryTab({
agent,
workingMemory,
onSave
}: {
agent: Agent
workingMemory: string
onSave: (content: string, append?: boolean) => Promise<void>
}) {
const [editing, setEditing] = useState(false)
const [content, setContent] = useState(workingMemory)
const [appendMode, setAppendMode] = useState(false)
const [newEntry, setNewEntry] = useState('')
useEffect(() => {
setContent(workingMemory)
}, [workingMemory])
const handleSave = async () => {
if (appendMode && newEntry.trim()) {
await onSave(newEntry, true)
setNewEntry('')
setAppendMode(false)
} else {
await onSave(content)
}
setEditing(false)
}
const handleClear = async () => {
if (confirm('Are you sure you want to clear all working memory?')) {
await onSave('')
setContent('')
setEditing(false)
}
}
return (
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<h4 className="text-lg font-medium text-foreground">Working Memory</h4>
<div className="flex gap-2">
{!editing && (
<>
<button
onClick={() => {
setAppendMode(true)
setEditing(true)
}}
className="px-3 py-1 text-sm bg-green-500/20 text-green-400 border border-green-500/30 rounded-md hover:bg-green-500/30 transition-smooth"
>
Add Entry
</button>
<button
onClick={() => setEditing(true)}
className="px-3 py-1 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-smooth"
>
Edit Memory
</button>
</>
)}
</div>
</div>
{/* Memory Content */}
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Memory Content ({content.length} characters)
</label>
{editing && appendMode ? (
<div className="space-y-2">
<div className="bg-surface-1/30 rounded p-4 max-h-40 overflow-y-auto">
<pre className="text-foreground whitespace-pre-wrap text-sm">{content}</pre>
</div>
<textarea
value={newEntry}
onChange={(e) => setNewEntry(e.target.value)}
rows={5}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
placeholder="Add new memory entry..."
/>
</div>
) : editing ? (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={15}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50 font-mono text-sm"
placeholder="Working memory for temporary notes, current tasks, and session data..."
/>
) : (
<div className="bg-surface-1/30 rounded p-4 max-h-96 overflow-y-auto">
{content ? (
<pre className="text-foreground whitespace-pre-wrap text-sm">{content}</pre>
) : (
<p className="text-muted-foreground italic">No working memory content</p>
)}
</div>
)}
</div>
{/* Actions */}
{editing && (
<div className="flex gap-3">
<button
onClick={handleSave}
className="flex-1 bg-primary text-primary-foreground py-2 rounded-md hover:bg-primary/90 transition-smooth"
>
{appendMode ? 'Add Entry' : 'Save Memory'}
</button>
<button
onClick={() => {
setEditing(false)
setAppendMode(false)
setContent(workingMemory)
setNewEntry('')
}}
className="flex-1 bg-secondary text-muted-foreground py-2 rounded-md hover:bg-surface-2 transition-smooth"
>
Cancel
</button>
{!appendMode && (
<button
onClick={handleClear}
className="px-4 py-2 bg-red-500/20 text-red-400 border border-red-500/30 rounded-md hover:bg-red-500/30 transition-smooth"
>
Clear All
</button>
)}
</div>
)}
</div>
)
}
// Tasks Tab Component
export function TasksTab({ agent }: { agent: Agent }) {
const [tasks, setTasks] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchTasks = async () => {
try {
const response = await fetch(`/api/tasks?assigned_to=${agent.name}`)
if (response.ok) {
const data = await response.json()
setTasks(data.tasks || [])
}
} catch (error) {
console.error('Failed to fetch tasks:', error)
} finally {
setLoading(false)
}
}
fetchTasks()
}, [agent.name])
if (loading) {
return (
<div className="p-6">
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span className="ml-2 text-muted-foreground">Loading tasks...</span>
</div>
</div>
)
}
return (
<div className="p-6 space-y-4">
<h4 className="text-lg font-medium text-foreground">Assigned Tasks</h4>
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground/50">
<div className="w-10 h-10 rounded-full bg-surface-2 flex items-center justify-center mb-2">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<rect x="3" y="2" width="10" height="12" rx="1" />
<path d="M6 6h4M6 9h3" />
</svg>
</div>
<p className="text-sm">No tasks assigned</p>
</div>
) : (
<div className="space-y-3">
{tasks.map(task => (
<div key={task.id} className="bg-surface-1/50 rounded-lg p-4">
<div className="flex items-start justify-between">
<div>
<h5 className="font-medium text-foreground">{task.title}</h5>
{task.description && (
<p className="text-foreground/80 text-sm mt-1">{task.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 text-xs rounded-md font-medium ${
task.status === 'in_progress' ? 'bg-yellow-500/20 text-yellow-400' :
task.status === 'done' ? 'bg-green-500/20 text-green-400' :
task.status === 'review' ? 'bg-blue-500/20 text-blue-400' :
task.status === 'quality_review' ? 'bg-indigo-500/20 text-indigo-400' :
'bg-secondary text-muted-foreground'
}`}>
{task.status}
</span>
<span className={`px-2 py-1 text-xs rounded-md font-medium ${
task.priority === 'urgent' ? 'bg-red-500/20 text-red-400' :
task.priority === 'high' ? 'bg-orange-500/20 text-orange-400' :
task.priority === 'medium' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-secondary text-muted-foreground'
}`}>
{task.priority}
</span>
</div>
</div>
{task.due_date && (
<div className="text-xs text-muted-foreground mt-2">
Due: {new Date(task.due_date * 1000).toLocaleDateString()}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
// Activity Tab Component
export function ActivityTab({ agent }: { agent: Agent }) {
const [activities, setActivities] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchActivities = async () => {
try {
const response = await fetch(`/api/activities?actor=${agent.name}&limit=50`)
if (response.ok) {
const data = await response.json()
setActivities(data.activities || [])
}
} catch (error) {
console.error('Failed to fetch activities:', error)
} finally {
setLoading(false)
}
}
fetchActivities()
}, [agent.name])
if (loading) {
return (
<div className="p-6">
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span className="ml-2 text-muted-foreground">Loading activity...</span>
</div>
</div>
)
}
const getActivityIcon = (type: string) => {
switch (type) {
case 'agent_status_change': return '~'
case 'task_created': return '+'
case 'task_updated': return '>'
case 'comment_added': return '#'
case 'agent_heartbeat': return '*'
case 'agent_soul_updated': return '@'
case 'agent_memory_updated': return '='
default: return '.'
}
}
return (
<div className="p-6 space-y-4">
<h4 className="text-lg font-medium text-foreground">Recent Activity</h4>
{activities.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground/50">
<div className="w-10 h-10 rounded-full bg-surface-2 flex items-center justify-center mb-2">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M2 4h12M2 8h8M2 12h10" />
</svg>
</div>
<p className="text-sm">No recent activity</p>
</div>
) : (
<div className="space-y-3">
{activities.map(activity => (
<div key={activity.id} className="bg-surface-1/50 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">{getActivityIcon(activity.type)}</div>
<div className="flex-1">
<p className="text-foreground">{activity.description}</p>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span>{activity.type}</span>
<span></span>
<span>{new Date(activity.created_at * 1000).toLocaleString()}</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
// ===== NEW COMPONENTS: CreateAgentModal (template wizard) + ConfigTab =====
// These replace the old CreateAgentModal and add the Config tab
// Template data for the wizard (client-side mirror of agent-templates.ts)
const TEMPLATES = [
{ type: 'orchestrator', label: 'Orchestrator', emoji: '\ud83e\udded', description: 'Primary coordinator with full tool access', modelTier: 'opus' as const, toolCount: 23, theme: 'operator strategist' },
{ type: 'developer', label: 'Developer', emoji: '\ud83d\udee0\ufe0f', description: 'Full-stack builder with Docker bridge', modelTier: 'sonnet' as const, toolCount: 21, theme: 'builder engineer' },
{ type: 'specialist-dev', label: 'Specialist Dev', emoji: '\u2699\ufe0f', description: 'Focused developer for specific domains', modelTier: 'sonnet' as const, toolCount: 15, theme: 'specialist developer' },
{ type: 'reviewer', label: 'Reviewer / QA', emoji: '\ud83d\udd2c', description: 'Read-only code review and quality gates', modelTier: 'haiku' as const, toolCount: 7, theme: 'quality reviewer' },
{ type: 'researcher', label: 'Researcher', emoji: '\ud83d\udd0d', description: 'Browser and web access for research', modelTier: 'sonnet' as const, toolCount: 8, theme: 'research analyst' },
{ type: 'content-creator', label: 'Content Creator', emoji: '\u270f\ufe0f', description: 'Write and edit for content generation', modelTier: 'haiku' as const, toolCount: 9, theme: 'content creator' },
{ type: 'security-auditor', label: 'Security Auditor', emoji: '\ud83d\udee1\ufe0f', description: 'Read-only + bash for security scanning', modelTier: 'sonnet' as const, toolCount: 10, theme: 'security auditor' },
]
const MODEL_TIER_COLORS: Record<string, string> = {
opus: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
sonnet: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
haiku: 'bg-green-500/20 text-green-400 border-green-500/30',
}
const MODEL_TIER_LABELS: Record<string, string> = {
opus: 'Opus $$$',
sonnet: 'Sonnet $$',
haiku: 'Haiku $',
}
const DEFAULT_MODEL_BY_TIER: Record<'opus' | 'sonnet' | 'haiku', string> = {
opus: 'anthropic/claude-opus-4-5',
sonnet: 'anthropic/claude-sonnet-4-20250514',
haiku: 'anthropic/claude-haiku-4-5',
}
// Enhanced Create Agent Modal with Template Wizard
export function CreateAgentModal({
onClose,
onCreated
}: {
onClose: () => void
onCreated: () => void
}) {
const [step, setStep] = useState<1 | 2 | 3>(1)
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
const [availableModels, setAvailableModels] = useState<string[]>([])
const [formData, setFormData] = useState({
name: '',
id: '',
role: '',
emoji: '',
modelTier: 'sonnet' as 'opus' | 'sonnet' | 'haiku',
modelPrimary: DEFAULT_MODEL_BY_TIER.sonnet,
workspaceAccess: 'rw' as 'rw' | 'ro' | 'none',
sandboxMode: 'all' as 'all' | 'non-main',
dockerNetwork: 'none' as 'none' | 'bridge',
session_key: '',
write_to_gateway: true,
})
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const selectedTemplateData = TEMPLATES.find(t => t.type === selectedTemplate)
// Auto-generate kebab-case ID from name
const updateName = (name: string) => {
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
setFormData(prev => ({ ...prev, name, id }))
}
useEffect(() => {
const loadAvailableModels = async () => {
try {
const response = await fetch('/api/status?action=models')
if (!response.ok) return
const data = await response.json()
const models = Array.isArray(data.models) ? data.models : []
const names = models
.map((model: any) => String(model.name || model.alias || '').trim())
.filter(Boolean)
setAvailableModels(Array.from(new Set<string>(names)))
} catch {
// Keep modal usable without model suggestions.
}
}
loadAvailableModels()
}, [])
// When template is selected, pre-fill form
const selectTemplate = (type: string | null) => {
setSelectedTemplate(type)
if (type) {
const tmpl = TEMPLATES.find(t => t.type === type)
if (tmpl) {
setFormData(prev => ({
...prev,
role: tmpl.theme,
emoji: tmpl.emoji,
modelTier: tmpl.modelTier,
modelPrimary: DEFAULT_MODEL_BY_TIER[tmpl.modelTier],
workspaceAccess: type === 'researcher' || type === 'content-creator' ? 'none' : type === 'reviewer' || type === 'security-auditor' ? 'ro' : 'rw',
sandboxMode: type === 'orchestrator' ? 'non-main' : 'all',
dockerNetwork: type === 'developer' || type === 'specialist-dev' ? 'bridge' : 'none',
}))
}
}
}
const handleCreate = async () => {
if (!formData.name.trim()) {
setError('Name is required')
return
}
setIsCreating(true)
setError(null)
try {
const primaryModel = formData.modelPrimary.trim() || DEFAULT_MODEL_BY_TIER[formData.modelTier]
const response = await fetch('/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
role: formData.role,
session_key: formData.session_key || undefined,
template: selectedTemplate || undefined,
write_to_gateway: formData.write_to_gateway,
gateway_config: {
model: { primary: primaryModel },
identity: { name: formData.name, theme: formData.role, emoji: formData.emoji },
sandbox: {
mode: formData.sandboxMode,
workspaceAccess: formData.workspaceAccess,
scope: 'agent',
...(formData.dockerNetwork === 'bridge' ? { docker: { network: 'bridge' } } : {}),
},
},
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to create agent')
}
onCreated()
onClose()
} catch (err: any) {
setError(err.message)
} finally {
setIsCreating(false)
}
}
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg max-w-2xl w-full max-h-[85vh] flex flex-col">
{/* Header */}
<div className="p-6 border-b border-border flex-shrink-0">
<div className="flex justify-between items-center">
<div>
<h3 className="text-xl font-bold text-foreground">Create New Agent</h3>
<div className="flex gap-3 mt-2">
{[1, 2, 3].map(s => (
<div key={s} className="flex items-center gap-1.5">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
step === s ? 'bg-primary text-primary-foreground' :
step > s ? 'bg-green-500/20 text-green-400' :
'bg-surface-2 text-muted-foreground'
}`}>
{step > s ? '\u2713' : s}
</div>
<span className={`text-xs ${step === s ? 'text-foreground' : 'text-muted-foreground'}`}>
{s === 1 ? 'Template' : s === 2 ? 'Configure' : 'Review'}
</span>
</div>
))}
</div>
</div>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-2xl">x</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-3 mb-4 rounded-lg text-sm">
{error}
</div>
)}
{/* Step 1: Choose Template */}
{step === 1 && (
<div className="grid grid-cols-2 gap-3">
{TEMPLATES.map(tmpl => (
<button
key={tmpl.type}
onClick={() => { selectTemplate(tmpl.type); setStep(2) }}
className={`p-4 rounded-lg border text-left transition-smooth hover:bg-surface-1 ${
selectedTemplate === tmpl.type ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{tmpl.emoji}</span>
<span className="font-semibold text-foreground">{tmpl.label}</span>
</div>
<p className="text-xs text-muted-foreground mb-2">{tmpl.description}</p>
<div className="flex gap-2">
<span className={`px-2 py-0.5 text-xs rounded border ${MODEL_TIER_COLORS[tmpl.modelTier]}`}>
{MODEL_TIER_LABELS[tmpl.modelTier]}
</span>
<span className="px-2 py-0.5 text-xs rounded bg-surface-2 text-muted-foreground">
{tmpl.toolCount} tools
</span>
</div>
</button>
))}
{/* Custom option */}
<button
onClick={() => { selectTemplate(null); setStep(2) }}
className={`p-4 rounded-lg border text-left transition-smooth hover:bg-surface-1 border-dashed ${
selectedTemplate === null ? 'border-primary' : 'border-border'
}`}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">+</span>
<span className="font-semibold text-foreground">Custom</span>
</div>
<p className="text-xs text-muted-foreground">Start from scratch with blank config</p>
</button>
</div>
)}
{/* Step 2: Configure */}
{step === 2 && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-muted-foreground mb-1">Display Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => updateName(e.target.value)}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
placeholder="e.g., Frontend Dev"
autoFocus
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Agent ID</label>
<input
type="text"
value={formData.id}
onChange={(e) => setFormData(prev => ({ ...prev, id: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50 font-mono text-sm"
placeholder="frontend-dev"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-muted-foreground mb-1">Role / Theme</label>
<input
type="text"
value={formData.role}
onChange={(e) => setFormData(prev => ({ ...prev, role: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
placeholder="builder engineer"
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Emoji</label>
<input
type="text"
value={formData.emoji}
onChange={(e) => setFormData(prev => ({ ...prev, emoji: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
placeholder="e.g. \ud83d\udee0\ufe0f"
/>
</div>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Model Tier</label>
<div className="flex gap-2">
{(['opus', 'sonnet', 'haiku'] as const).map(tier => (
<button
key={tier}
onClick={() => setFormData(prev => ({
...prev,
modelTier: tier,
modelPrimary: DEFAULT_MODEL_BY_TIER[tier],
}))}
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-smooth ${
formData.modelTier === tier ? MODEL_TIER_COLORS[tier] + ' border' : 'bg-surface-1 text-muted-foreground border-border'
}`}
>
{MODEL_TIER_LABELS[tier]}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Primary Model</label>
<input
type="text"
value={formData.modelPrimary}
onChange={(e) => setFormData(prev => ({ ...prev, modelPrimary: e.target.value }))}
list="create-agent-model-suggestions"
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50 font-mono text-sm"
placeholder={DEFAULT_MODEL_BY_TIER[formData.modelTier]}
/>
<datalist id="create-agent-model-suggestions">
{availableModels.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-muted-foreground mb-1">Workspace</label>
<select
value={formData.workspaceAccess}
onChange={(e) => setFormData(prev => ({ ...prev, workspaceAccess: e.target.value as any }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="rw">Read/Write</option>
<option value="ro">Read Only</option>
<option value="none">None</option>
</select>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Sandbox</label>
<select
value={formData.sandboxMode}
onChange={(e) => setFormData(prev => ({ ...prev, sandboxMode: e.target.value as any }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="all">All (Docker)</option>
<option value="non-main">Non-main</option>
</select>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Network</label>
<select
value={formData.dockerNetwork}
onChange={(e) => setFormData(prev => ({ ...prev, dockerNetwork: e.target.value as any }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="none">None (isolated)</option>
<option value="bridge">Bridge (internet)</option>
</select>
</div>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Session Key (optional)</label>
<input
type="text"
value={formData.session_key}
onChange={(e) => setFormData(prev => ({ ...prev, session_key: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
placeholder="OpenClaw session identifier"
/>
</div>
</div>
)}
{/* Step 3: Review */}
{step === 3 && (
<div className="space-y-4">
<div className="bg-surface-1/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3">
<span className="text-3xl">{formData.emoji || (selectedTemplateData?.emoji || '?')}</span>
<div>
<h4 className="text-lg font-bold text-foreground">{formData.name || 'Unnamed'}</h4>
<p className="text-muted-foreground text-sm">{formData.role}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><span className="text-muted-foreground">ID:</span> <span className="text-foreground font-mono">{formData.id}</span></div>
<div><span className="text-muted-foreground">Template:</span> <span className="text-foreground">{selectedTemplateData?.label || 'Custom'}</span></div>
<div><span className="text-muted-foreground">Model:</span> <span className={`px-2 py-0.5 rounded text-xs ${MODEL_TIER_COLORS[formData.modelTier]}`}>{MODEL_TIER_LABELS[formData.modelTier]}</span></div>
<div><span className="text-muted-foreground">Tools:</span> <span className="text-foreground">{selectedTemplateData?.toolCount || 'Custom'}</span></div>
<div className="col-span-2"><span className="text-muted-foreground">Primary Model:</span> <span className="text-foreground font-mono">{formData.modelPrimary || DEFAULT_MODEL_BY_TIER[formData.modelTier]}</span></div>
<div><span className="text-muted-foreground">Workspace:</span> <span className="text-foreground">{formData.workspaceAccess}</span></div>
<div><span className="text-muted-foreground">Sandbox:</span> <span className="text-foreground">{formData.sandboxMode}</span></div>
<div><span className="text-muted-foreground">Network:</span> <span className="text-foreground">{formData.dockerNetwork}</span></div>
{formData.session_key && (
<div><span className="text-muted-foreground">Session:</span> <span className="text-foreground font-mono">{formData.session_key}</span></div>
)}
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.write_to_gateway}
onChange={(e) => setFormData(prev => ({ ...prev, write_to_gateway: e.target.checked }))}
className="w-4 h-4 rounded border-border"
/>
<span className="text-sm text-foreground">Add to gateway config (openclaw.json)</span>
</label>
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-border flex gap-3 flex-shrink-0">
{step > 1 && (
<button
onClick={() => setStep((step - 1) as 1 | 2)}
className="px-4 py-2 bg-secondary text-muted-foreground rounded-md hover:bg-surface-2 transition-smooth"
>
Back
</button>
)}
<div className="flex-1" />
{step < 3 ? (
<button
onClick={() => setStep((step + 1) as 2 | 3)}
disabled={step === 2 && !formData.name.trim()}
className="px-6 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-smooth"
>
Next
</button>
) : (
<button
onClick={handleCreate}
disabled={isCreating || !formData.name.trim()}
className="px-6 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-smooth"
>
{isCreating ? 'Creating...' : 'Create Agent'}
</button>
)}
<button onClick={onClose} className="px-4 py-2 bg-secondary text-muted-foreground rounded-md hover:bg-surface-2 transition-smooth">
Cancel
</button>
</div>
</div>
</div>
)
}
// Config Tab Component for Agent Detail Modal
export function ConfigTab({
agent,
onSave
}: {
agent: Agent & { config?: any }
onSave: () => void
}) {
const [config, setConfig] = useState<any>(agent.config || {})
const [editing, setEditing] = useState(false)
const [showJson, setShowJson] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [jsonInput, setJsonInput] = useState('')
const [availableModels, setAvailableModels] = useState<string[]>([])
const [newFallbackModel, setNewFallbackModel] = useState('')
useEffect(() => {
setConfig(agent.config || {})
setJsonInput(JSON.stringify(agent.config || {}, null, 2))
}, [agent.config])
useEffect(() => {
const loadAvailableModels = async () => {
try {
const response = await fetch('/api/status?action=models')
if (!response.ok) return
const data = await response.json()
const models = Array.isArray(data.models) ? data.models : []
const names = models
.map((model: any) => String(model.name || model.alias || '').trim())
.filter(Boolean)
setAvailableModels(Array.from(new Set<string>(names)))
} catch {
// Ignore model suggestions if unavailable.
}
}
loadAvailableModels()
}, [])
const updateModelConfig = (updater: (current: { primary?: string; fallbacks?: string[] }) => { primary?: string; fallbacks?: string[] }) => {
setConfig((prev: any) => {
const nextModel = updater({ ...(prev?.model || {}) })
const dedupedFallbacks = [...new Set((nextModel.fallbacks || []).map((value) => value.trim()).filter(Boolean))]
return {
...prev,
model: {
...nextModel,
fallbacks: dedupedFallbacks,
},
}
})
}
const addFallbackModel = () => {
const trimmed = newFallbackModel.trim()
if (!trimmed) return
updateModelConfig((current) => ({
...current,
fallbacks: [...(current.fallbacks || []), trimmed],
}))
setNewFallbackModel('')
}
const handleSave = async (writeToGateway: boolean = false) => {
setSaving(true)
setError(null)
try {
if (!showJson) {
const primary = String(config?.model?.primary || '').trim()
if (!primary) {
throw new Error('Primary model is required')
}
}
const response = await fetch(`/api/agents/${agent.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gateway_config: showJson ? JSON.parse(jsonInput) : config,
write_to_gateway: writeToGateway,
}),
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Failed to save')
if (data.warning) setError(data.warning)
setEditing(false)
onSave()
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
const model = config.model || {}
const identity = config.identity || {}
const sandbox = config.sandbox || {}
const tools = config.tools || {}
const subagents = config.subagents || {}
const memorySearch = config.memorySearch || {}
const sandboxMode = sandbox.mode || sandbox.sandboxMode || sandbox.sandbox_mode || config.sandboxMode || 'not configured'
const sandboxWorkspace = sandbox.workspaceAccess || sandbox.workspace_access || sandbox.workspace || config.workspaceAccess || 'not configured'
const sandboxNetwork = sandbox?.docker?.network || sandbox.network || sandbox.dockerNetwork || sandbox.docker_network || 'none'
const identityName = identity.name || agent.name || 'not configured'
const identityTheme = identity.theme || agent.role || 'not configured'
const identityEmoji = identity.emoji || '?'
const identityPreview = identity.content || ''
const toolAllow = Array.isArray(tools.allow) ? tools.allow : []
const toolDeny = Array.isArray(tools.deny) ? tools.deny : []
const toolRawPreview = typeof tools.raw === 'string' ? tools.raw : ''
const modelPrimary = model.primary || ''
const modelFallbacks = Array.isArray(model.fallbacks) ? model.fallbacks : []
return (
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<h4 className="text-lg font-medium text-foreground">OpenClaw Config</h4>
<div className="flex gap-2">
<button
onClick={() => setShowJson(!showJson)}
className="px-3 py-1 text-xs bg-surface-2 text-muted-foreground rounded-md hover:bg-surface-1 transition-smooth"
>
{showJson ? 'Structured' : 'JSON'}
</button>
{!editing && (
<button
onClick={() => setEditing(true)}
className="px-3 py-1 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-smooth"
>
Edit
</button>
)}
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-3 rounded-lg text-sm">
{error}
</div>
)}
{config.openclawId && (
<div className="text-xs text-muted-foreground">
OpenClaw ID: <span className="font-mono text-foreground">{config.openclawId}</span>
{config.isDefault && <span className="ml-2 px-1.5 py-0.5 bg-primary/20 text-primary rounded text-xs">Default</span>}
</div>
)}
{showJson ? (
/* JSON view */
<div>
{editing ? (
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
rows={20}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
) : (
<pre className="bg-surface-1/30 rounded p-4 text-xs text-foreground/90 overflow-auto max-h-96 font-mono">
{JSON.stringify(config, null, 2)}
</pre>
)}
</div>
) : (
/* Structured view */
<div className="space-y-4">
{/* Model */}
<div className="bg-surface-1/50 rounded-lg p-4">
<h5 className="text-sm font-medium text-foreground mb-2">Model</h5>
{editing ? (
<div className="space-y-3">
<div>
<label className="block text-xs text-muted-foreground mb-1">Primary model</label>
<input
value={modelPrimary}
onChange={(e) => updateModelConfig((current) => ({ ...current, primary: e.target.value }))}
list="agent-model-suggestions"
placeholder="anthropic/claude-sonnet-4-20250514"
className="w-full bg-surface-1 text-foreground rounded px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<datalist id="agent-model-suggestions">
{availableModels.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Fallback models</label>
<div className="space-y-2">
{modelFallbacks.map((fallback: string, index: number) => (
<div key={`${fallback}-${index}`} className="flex gap-2">
<input
value={fallback}
onChange={(e) => {
const next = [...modelFallbacks]
next[index] = e.target.value
updateModelConfig((current) => ({ ...current, fallbacks: next }))
}}
list="agent-model-suggestions"
className="flex-1 bg-surface-1 text-foreground rounded px-3 py-2 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
onClick={() => {
const next = modelFallbacks.filter((_: string, i: number) => i !== index)
updateModelConfig((current) => ({ ...current, fallbacks: next }))
}}
className="px-3 py-2 text-xs bg-red-500/10 text-red-400 border border-red-500/30 rounded hover:bg-red-500/20 transition-smooth"
>
Remove
</button>
</div>
))}
<div className="flex gap-2">
<input
value={newFallbackModel}
onChange={(e) => setNewFallbackModel(e.target.value)}
list="agent-model-suggestions"
placeholder="Add fallback model"
className="flex-1 bg-surface-1 text-foreground rounded px-3 py-2 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
onClick={addFallbackModel}
className="px-3 py-2 text-xs bg-secondary text-foreground rounded hover:bg-surface-2 transition-smooth"
>
Add
</button>
</div>
</div>
</div>
</div>
) : (
<div className="text-sm">
<div><span className="text-muted-foreground">Primary:</span> <span className="text-foreground font-mono">{modelPrimary || 'not configured'}</span></div>
{modelFallbacks.length > 0 && (
<div className="mt-1">
<span className="text-muted-foreground">Fallbacks:</span>
<div className="flex flex-wrap gap-1 mt-1">
{modelFallbacks.map((fb: string, i: number) => (
<span key={i} className="px-2 py-0.5 text-xs bg-surface-2 rounded text-muted-foreground font-mono">{fb.split('/').pop()}</span>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Identity */}
<div className="bg-surface-1/50 rounded-lg p-4">
<h5 className="text-sm font-medium text-foreground mb-2">Identity</h5>
<div className="flex items-center gap-3 text-sm">
<span className="text-2xl">{identityEmoji}</span>
<div>
<div className="text-foreground font-medium">{identityName}</div>
<div className="text-muted-foreground">{identityTheme}</div>
</div>
</div>
{identityPreview && (
<pre className="mt-3 text-xs text-muted-foreground bg-surface-1 rounded p-2 overflow-auto whitespace-pre-wrap">
{identityPreview}
</pre>
)}
</div>
{/* Sandbox */}
<div className="bg-surface-1/50 rounded-lg p-4">
<h5 className="text-sm font-medium text-foreground mb-2">Sandbox</h5>
<div className="grid grid-cols-3 gap-2 text-sm">
<div><span className="text-muted-foreground">Mode:</span> <span className="text-foreground">{sandboxMode}</span></div>
<div><span className="text-muted-foreground">Workspace:</span> <span className="text-foreground">{sandboxWorkspace}</span></div>
<div><span className="text-muted-foreground">Network:</span> <span className="text-foreground">{sandboxNetwork}</span></div>
</div>
</div>
{/* Tools */}
<div className="bg-surface-1/50 rounded-lg p-4">
<h5 className="text-sm font-medium text-foreground mb-2">Tools</h5>
{toolAllow.length > 0 && (
<div className="mb-2">
<span className="text-xs text-green-400 font-medium">Allow ({toolAllow.length}):</span>
<div className="flex flex-wrap gap-1 mt-1">
{toolAllow.map((tool: string) => (
<span key={tool} className="px-2 py-0.5 text-xs bg-green-500/10 text-green-400 rounded border border-green-500/20">{tool}</span>
))}
</div>
</div>
)}
{toolDeny.length > 0 && (
<div>
<span className="text-xs text-red-400 font-medium">Deny ({toolDeny.length}):</span>
<div className="flex flex-wrap gap-1 mt-1">
{toolDeny.map((tool: string) => (
<span key={tool} className="px-2 py-0.5 text-xs bg-red-500/10 text-red-400 rounded border border-red-500/20">{tool}</span>
))}
</div>
</div>
)}
{toolAllow.length === 0 && toolDeny.length === 0 && !toolRawPreview && (
<div className="text-xs text-muted-foreground">No tools configured</div>
)}
{toolRawPreview && (
<pre className="mt-3 text-xs text-muted-foreground bg-surface-1 rounded p-2 overflow-auto whitespace-pre-wrap">
{toolRawPreview}
</pre>
)}
</div>
{/* Subagents */}
{subagents.allowAgents && subagents.allowAgents.length > 0 && (
<div className="bg-surface-1/50 rounded-lg p-4">
<h5 className="text-sm font-medium text-foreground mb-2">Subagents</h5>
<div className="flex flex-wrap gap-1">
{subagents.allowAgents.map((a: string) => (
<span key={a} className="px-2 py-0.5 text-xs bg-blue-500/10 text-blue-400 rounded border border-blue-500/20">{a}</span>
))}
</div>
{subagents.model && (
<div className="text-xs text-muted-foreground mt-1">Model: {subagents.model}</div>
)}
</div>
)}
{/* Memory Search */}
{memorySearch.sources && (
<div className="bg-surface-1/50 rounded-lg p-4">
<h5 className="text-sm font-medium text-foreground mb-2">Memory Search</h5>
<div className="flex gap-1">
{memorySearch.sources.map((s: string) => (
<span key={s} className="px-2 py-0.5 text-xs bg-cyan-500/10 text-cyan-400 rounded">{s}</span>
))}
</div>
</div>
)}
</div>
)}
{/* Actions */}
{editing && (
<div className="flex gap-3 pt-2">
<button
onClick={() => handleSave(false)}
disabled={saving}
className="flex-1 bg-primary text-primary-foreground py-2 rounded-md hover:bg-primary/90 disabled:opacity-50 transition-smooth"
>
{saving ? 'Saving...' : 'Save to MC'}
</button>
<button
onClick={() => handleSave(true)}
disabled={saving}
className="flex-1 bg-green-600 text-white py-2 rounded-md hover:bg-green-700 disabled:opacity-50 transition-smooth"
>
Save to Gateway
</button>
<button
onClick={() => {
setEditing(false)
setConfig(agent.config || {})
setJsonInput(JSON.stringify(agent.config || {}, null, 2))
}}
className="px-4 py-2 bg-secondary text-muted-foreground rounded-md hover:bg-surface-2 transition-smooth"
>
Cancel
</button>
</div>
)}
</div>
)
}