This repository has been archived on 2026-04-05. You can view files and clone it, but cannot push or open issues or pull requests.
claude-persistent-agent/frontend/src/App.jsx

586 lines
33 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef, useCallback } from 'react';
import './App.css';
const App = () => {
const [tasks, setTasks] = useState([]);
const [selectedTask, setSelectedTask] = useState(null);
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({
name: '', description: '', prompt: '', schedule_type: 'manual',
schedule_value: '', enabled: true,
agent_model: '', agent_tools: 'Bash,Read,Write,Edit',
agent_system_prompt: '', agent_permission_mode: 'auto', agent_timeout: 300
});
const [systemInfo, setSystemInfo] = useState(null);
const [usageStats, setUsageStats] = useState(null);
const [authStatus, setAuthStatus] = useState(null);
const [mcpServers, setMcpServers] = useState(null);
const [showAddMcp, setShowAddMcp] = useState(false);
const [mcpForm, setMcpForm] = useState({ name: '', server_type: 'sse', url: '', command: '' });
const [loading, setLoading] = useState(false);
const [activeView, setActiveView] = useState('chat'); // 'chat' | 'tasks' | 'dashboard'
// Auth
const [authToken, setAuthToken] = useState('');
const [tokenType, setTokenType] = useState('oauth_token');
const [tokenSubmitting, setTokenSubmitting] = useState(false);
// Chat
const [chatMessages, setChatMessages] = useState([]);
const [chatInput, setChatInput] = useState('');
const [chatSending, setChatSending] = useState(false);
const [chatSessionId, setChatSessionId] = useState(() => crypto.randomUUID ? crypto.randomUUID() : Date.now().toString());
const [chatSessions, setChatSessions] = useState([]);
const [chatModel, setChatModel] = useState('');
const chatEndRef = useRef(null);
const wsRef = useRef(null);
const [wsConnected, setWsConnected] = useState(false);
useEffect(() => {
fetchTasks(); fetchSystemInfo(); fetchUsageStats(); fetchAuthStatus(); fetchMcpServers();
const interval = setInterval(() => {
fetchTasks(); fetchSystemInfo(); fetchAuthStatus();
}, 10000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (chatEndRef.current) chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages]);
// WebSocket for chat
const connectWs = useCallback(() => {
if (wsRef.current && wsRef.current.readyState <= 1) return;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${window.location.host}/api/chat/ws/${chatSessionId}`);
ws.onopen = () => setWsConnected(true);
ws.onclose = () => setWsConnected(false);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'chunk') {
setChatMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.streaming) {
return [...prev.slice(0, -1), { ...last, content: last.content + data.content }];
}
return [...prev, { role: 'assistant', content: data.content, streaming: true }];
});
} else if (data.type === 'done') {
setChatMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.streaming) {
return [...prev.slice(0, -1), { ...last, streaming: false }];
}
return prev;
});
setChatSending(false);
} else if (data.type === 'error') {
setChatMessages(prev => [...prev, { role: 'error', content: data.content }]);
setChatSending(false);
} else if (data.type === 'status' && data.content === 'thinking') {
setChatMessages(prev => [...prev, { role: 'assistant', content: '', streaming: true }]);
}
};
wsRef.current = ws;
}, [chatSessionId]);
useEffect(() => {
if (activeView === 'chat') connectWs();
return () => {
if (wsRef.current) { wsRef.current.close(); wsRef.current = null; }
};
}, [activeView, chatSessionId, connectWs]);
const sendChatMessage = async () => {
if (!chatInput.trim() || chatSending) return;
const msg = chatInput.trim();
setChatInput('');
setChatMessages(prev => [...prev, { role: 'user', content: msg }]);
setChatSending(true);
if (wsRef.current && wsRef.current.readyState === 1) {
wsRef.current.send(JSON.stringify({
message: msg, model: chatModel || undefined
}));
} else {
// Fallback to HTTP
try {
const response = await fetch('/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: msg, session_id: chatSessionId, model: chatModel || undefined
})
});
const data = await response.json();
setChatMessages(prev => [...prev, {
role: data.status === 'ok' ? 'assistant' : 'error',
content: data.response
}]);
} catch (error) {
setChatMessages(prev => [...prev, { role: 'error', content: 'Failed to send message' }]);
}
setChatSending(false);
}
};
const startNewChat = () => {
setChatMessages([]);
const newId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString();
setChatSessionId(newId);
if (wsRef.current) { wsRef.current.close(); wsRef.current = null; }
};
const loadChatSession = async (sid) => {
setChatSessionId(sid);
if (wsRef.current) { wsRef.current.close(); wsRef.current = null; }
try {
const response = await fetch(`/api/chat/history/${sid}`);
const data = await response.json();
setChatMessages(data.map(m => ({ role: m.role, content: m.content })));
} catch (e) {
console.error('Error loading chat session:', e);
}
};
// Fetchers
const fetchTasks = async () => { try { const r = await fetch('/api/tasks'); setTasks(await r.json()); } catch(e) {} };
const fetchSystemInfo = async () => { try { const r = await fetch('/api/system/info'); setSystemInfo(await r.json()); } catch(e) {} };
const fetchUsageStats = async () => { try { const r = await fetch('/api/system/usage'); setUsageStats(await r.json()); } catch(e) {} };
const fetchAuthStatus = async () => { try { const r = await fetch('/api/auth/status'); setAuthStatus(await r.json()); } catch(e) {} };
const fetchMcpServers = async () => { try { const r = await fetch('/api/mcp/servers'); setMcpServers(await r.json()); } catch(e) {} };
const fetchChatSessions = async () => { try { const r = await fetch('/api/chat/sessions'); setChatSessions(await r.json()); } catch(e) {} };
const handleSubmitToken = async (e) => {
e.preventDefault();
if (!authToken.trim()) return;
setTokenSubmitting(true);
try {
const r = await fetch('/api/auth/token', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: authToken.trim(), token_type: tokenType })
});
const data = await r.json();
setAuthToken('');
fetchAuthStatus();
if (data.status !== 'logged_in') alert(data.message);
} catch(e) { alert('Failed to submit token'); }
setTokenSubmitting(false);
};
const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }); fetchAuthStatus(); };
const handleAddMcp = async (e) => {
e.preventDefault();
const r = await fetch('/api/mcp/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mcpForm) });
const data = await r.json();
if (data.status === 'ok') { setShowAddMcp(false); setMcpForm({ name: '', server_type: 'sse', url: '', command: '' }); fetchMcpServers(); }
else alert(data.message || 'Failed');
};
const handleRemoveMcp = async (name) => { if (!confirm(`Remove "${name}"?`)) return; await fetch(`/api/mcp/servers/${name}`, { method: 'DELETE' }); fetchMcpServers(); };
const handleCreateTask = async (e) => {
e.preventDefault(); setLoading(true);
try {
const r = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) });
if (r.ok) {
await fetchTasks(); setShowForm(false);
setFormData({ name: '', description: '', prompt: '', schedule_type: 'manual', schedule_value: '', enabled: true,
agent_model: '', agent_tools: 'Bash,Read,Write,Edit', agent_system_prompt: '', agent_permission_mode: 'auto', agent_timeout: 300 });
}
} catch(e) {}
setLoading(false);
};
const handleRunTask = async (taskId) => { await fetch(`/api/tasks/${taskId}/run`, { method: 'POST' }); await fetchTasks(); };
const handleDeleteTask = async (taskId) => {
if (confirm('Delete this task?')) { await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' }); await fetchTasks(); setSelectedTask(null); }
};
const getStatusColor = (s) => ({ running: '#3498db', completed: '#27ae60', failed: '#e74c3c' }[s] || '#95a5a6');
return (
<div className="app-container">
<header className="app-header">
<div className="header-content">
<h1>Claude Persistent Agent</h1>
<p>Chat, schedule tasks & orchestrate agents</p>
</div>
<div className="header-right">
<nav className="header-nav">
<button className={`nav-btn ${activeView === 'chat' ? 'active' : ''}`} onClick={() => setActiveView('chat')}>Chat</button>
<button className={`nav-btn ${activeView === 'tasks' ? 'active' : ''}`} onClick={() => setActiveView('tasks')}>Agents</button>
<button className={`nav-btn ${activeView === 'dashboard' ? 'active' : ''}`} onClick={() => setActiveView('dashboard')}>Dashboard</button>
</nav>
<div className="auth-badge">
{authStatus?.status === 'logged_in' ? (
<span className="auth-ok" title={authStatus.account || 'Authenticated'}> {authStatus.account || 'Authenticated'}</span>
) : authStatus?.has_saved_token ? (
<span className="auth-ok auth-token-saved" title="Token saved"> Token saved</span>
) : (
<span className="auth-disconnected-badge" onClick={() => setActiveView('dashboard')} style={{cursor:'pointer'}}> Not authenticated</span>
)}
</div>
{systemInfo && (
<div className="system-status">
<span className={`status-indicator ${systemInfo.scheduler_running ? 'running' : 'stopped'}`}></span>
<span>{systemInfo.task_count} agents · {systemInfo.total_runs || 0} runs</span>
</div>
)}
</div>
</header>
<main className="app-main">
{/* ===== CHAT VIEW ===== */}
{activeView === 'chat' && (
<div className="chat-view">
<div className="chat-sidebar">
<button className="btn btn-primary btn-sm chat-new-btn" onClick={startNewChat}>+ New Chat</button>
<div className="chat-model-select">
<select value={chatModel} onChange={e => setChatModel(e.target.value)}>
<option value="">Default Model</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="haiku">Haiku</option>
</select>
</div>
<div className="chat-sessions-list">
<button className="btn btn-secondary btn-xs" onClick={fetchChatSessions} style={{width:'100%',marginBottom:'0.5rem'}}>Load History</button>
{chatSessions.map(s => (
<div key={s.session_id} className={`chat-session-item ${s.session_id === chatSessionId ? 'active' : ''}`}
onClick={() => loadChatSession(s.session_id)}>
<div className="chat-session-preview">{(s.first_message || 'Chat').substring(0, 40)}</div>
<div className="chat-session-meta">{s.message_count} msgs · {new Date(s.last_message).toLocaleDateString()}</div>
</div>
))}
</div>
<div className="chat-ws-status">
<span className={`ws-dot ${wsConnected ? 'connected' : 'disconnected'}`}></span>
{wsConnected ? 'Connected' : 'Disconnected'}
</div>
</div>
<div className="chat-main">
<div className="chat-messages">
{chatMessages.length === 0 && (
<div className="chat-empty">
<div className="chat-empty-icon">💬</div>
<h3>Chat with Claude</h3>
<p>Send a message to start a conversation with the Claude Code instance running on your server.</p>
<p className="chat-empty-hint">Claude has access to Bash, file system, and any MCP servers you've configured.</p>
</div>
)}
{chatMessages.map((msg, i) => (
<div key={i} className={`chat-msg chat-msg-${msg.role}`}>
<div className="chat-msg-avatar">
{msg.role === 'user' ? '👤' : msg.role === 'error' ? '' : '🤖'}
</div>
<div className="chat-msg-content">
<pre className="chat-msg-text">{msg.content}{msg.streaming ? '' : ''}</pre>
</div>
</div>
))}
<div ref={chatEndRef} />
</div>
<div className="chat-input-area">
<textarea
className="chat-input"
value={chatInput}
onChange={e => setChatInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); } }}
placeholder={chatSending ? 'Claude is thinking...' : 'Send a message... (Enter to send, Shift+Enter for newline)'}
disabled={chatSending}
rows={2}
/>
<button className="chat-send-btn" onClick={sendChatMessage} disabled={chatSending || !chatInput.trim()}>
{chatSending ? '' : ''}
</button>
</div>
</div>
</div>
)}
{/* ===== TASKS/AGENTS VIEW ===== */}
{activeView === 'tasks' && (<>
<aside className="sidebar">
<button className="btn btn-primary btn-create" onClick={() => setShowForm(true)}>+ New Agent Task</button>
<div className="tasks-list">
<h2>Agent Tasks</h2>
{tasks.length === 0 ? (
<p className="empty-state">No agent tasks yet. Create one to get started.</p>
) : (
tasks.map((task) => (
<div key={task.id} className={`task-item ${selectedTask?.id === task.id ? 'active' : ''}`} onClick={() => setSelectedTask(task)}>
<div className="task-item-header">
<h3>{task.name}</h3>
<span className="status-badge" style={{ backgroundColor: getStatusColor(task.status) }}>{task.status}</span>
</div>
<p className="task-schedule">
{task.schedule_type === 'manual' ? ' Manual' : `🔄 ${task.schedule_type}: ${task.schedule_value}`}
{task.agent_model && ` · ${task.agent_model}`}
</p>
{task.last_run && <p className="task-last-run">Last: {new Date(task.last_run).toLocaleString()}</p>}
</div>
))
)}
</div>
</aside>
<section className="content">
{showForm ? (
<div className="form-container">
<h2>Create Agent Task</h2>
<form onSubmit={handleCreateTask} className="task-form">
<div className="form-group">
<label>Task Name</label>
<input type="text" required value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} placeholder="e.g., Daily Code Review" />
</div>
<div className="form-group">
<label>Description</label>
<textarea value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} placeholder="What does this agent do?" rows={2} />
</div>
<div className="form-group">
<label>Agent Prompt</label>
<textarea required value={formData.prompt} onChange={e => setFormData({...formData, prompt: e.target.value})} placeholder="Instructions for the Claude agent..." rows={6} />
</div>
<div className="form-section-title">Schedule</div>
<div className="form-row">
<div className="form-group">
<label>Schedule Type</label>
<select value={formData.schedule_type} onChange={e => setFormData({...formData, schedule_type: e.target.value})}>
<option value="manual">Manual Only</option>
<option value="recurring">Recurring (Cron)</option>
<option value="once">Once (Datetime)</option>
</select>
</div>
{formData.schedule_type === 'recurring' && (
<div className="form-group">
<label>Cron Expression</label>
<input type="text" value={formData.schedule_value} onChange={e => setFormData({...formData, schedule_value: e.target.value})} placeholder="0 9 * * *" />
</div>
)}
{formData.schedule_type === 'once' && (
<div className="form-group"><label>Run At</label><input type="datetime-local" value={formData.schedule_value} onChange={e => setFormData({...formData, schedule_value: e.target.value})} /></div>
)}
</div>
<div className="form-section-title">Agent Configuration</div>
<div className="form-row">
<div className="form-group">
<label>Model</label>
<select value={formData.agent_model} onChange={e => setFormData({...formData, agent_model: e.target.value})}>
<option value="">Default</option>
<option value="sonnet">Sonnet (fast)</option>
<option value="opus">Opus (powerful)</option>
<option value="haiku">Haiku (lightweight)</option>
</select>
</div>
<div className="form-group">
<label>Permission Mode</label>
<select value={formData.agent_permission_mode} onChange={e => setFormData({...formData, agent_permission_mode: e.target.value})}>
<option value="auto">Auto (full access)</option>
<option value="acceptEdits">Accept Edits Only</option>
<option value="plan">Plan Mode (read-only)</option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Allowed Tools</label>
<input type="text" value={formData.agent_tools} onChange={e => setFormData({...formData, agent_tools: e.target.value})} placeholder="Bash,Read,Write,Edit" />
</div>
<div className="form-group">
<label>Timeout (seconds)</label>
<input type="number" value={formData.agent_timeout} onChange={e => setFormData({...formData, agent_timeout: parseInt(e.target.value) || 300})} min={30} max={3600} />
</div>
</div>
<div className="form-group">
<label>Custom System Prompt (optional)</label>
<textarea value={formData.agent_system_prompt} onChange={e => setFormData({...formData, agent_system_prompt: e.target.value})} placeholder="Override the default system prompt..." rows={3} />
</div>
<div className="form-group checkbox">
<input type="checkbox" checked={formData.enabled} onChange={e => setFormData({...formData, enabled: e.target.checked})} />
<label>Enabled</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Creating...' : 'Create Agent Task'}</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowForm(false)}>Cancel</button>
</div>
</form>
</div>
) : selectedTask ? (
<div className="task-detail">
<div className="task-detail-header">
<h2>{selectedTask.name}</h2>
<div className="task-actions">
<button className="btn btn-success" onClick={() => handleRunTask(selectedTask.id)}>▶ Run Now</button>
<button className="btn btn-danger" onClick={() => handleDeleteTask(selectedTask.id)}>🗑 Delete</button>
</div>
</div>
<div className="task-meta">
<div className="meta-item"><span className="label">Status</span><span className="value" style={{color: getStatusColor(selectedTask.status)}}>{selectedTask.status}</span></div>
<div className="meta-item"><span className="label">Schedule</span><span className="value">{selectedTask.schedule_type === 'manual' ? 'Manual' : `${selectedTask.schedule_type}: ${selectedTask.schedule_value}`}</span></div>
<div className="meta-item"><span className="label">Model</span><span className="value">{selectedTask.agent_model || 'Default'}</span></div>
<div className="meta-item"><span className="label">Tools</span><span className="value">{selectedTask.agent_tools || 'Bash,Read,Write,Edit'}</span></div>
<div className="meta-item"><span className="label">Permission</span><span className="value">{selectedTask.agent_permission_mode || 'auto'}</span></div>
<div className="meta-item"><span className="label">Timeout</span><span className="value">{selectedTask.agent_timeout || 300}s</span></div>
<div className="meta-item"><span className="label">Created</span><span className="value">{new Date(selectedTask.created_at).toLocaleString()}</span></div>
{selectedTask.last_run && <div className="meta-item"><span className="label">Last Run</span><span className="value">{new Date(selectedTask.last_run).toLocaleString()}</span></div>}
</div>
{selectedTask.description && <div className="task-section"><h3>Description</h3><p>{selectedTask.description}</p></div>}
{selectedTask.agent_system_prompt && <div className="task-section"><h3>System Prompt</h3><pre className="prompt-display">{selectedTask.agent_system_prompt}</pre></div>}
<div className="task-section"><h3>Agent Prompt</h3><pre className="prompt-display">{selectedTask.prompt}</pre></div>
<TaskRuns taskId={selectedTask.id} />
</div>
) : (
<div className="empty-state-main">
<h2>Select an agent task to view details</h2>
<p>Or create a new one to get started</p>
</div>
)}
</section>
</>)}
{/* ===== DASHBOARD VIEW ===== */}
{activeView === 'dashboard' && (
<div className="dashboard-view">
<h2 className="dashboard-title">System Dashboard</h2>
<div className="dashboard-grid">
{systemInfo && (<>
<div className="dash-card"><div className="dash-card-icon">📋</div><div className="dash-card-value">{systemInfo.task_count}</div><div className="dash-card-label">Agent Tasks</div></div>
<div className="dash-card"><div className="dash-card-icon">✅</div><div className="dash-card-value">{systemInfo.completed_runs || 0}</div><div className="dash-card-label">Completed</div></div>
<div className="dash-card"><div className="dash-card-icon">❌</div><div className="dash-card-value">{systemInfo.failed_runs || 0}</div><div className="dash-card-label">Failed</div></div>
<div className="dash-card"><div className="dash-card-icon">⚡</div><div className="dash-card-value">{systemInfo.running_runs || 0}</div><div className="dash-card-label">Running</div></div>
<div className="dash-card"><div className="dash-card-icon">💬</div><div className="dash-card-value">{systemInfo.active_chat_sessions || 0}</div><div className="dash-card-label">Active Chats</div></div>
<div className="dash-card"><div className="dash-card-icon">{systemInfo.scheduler_running ? '🟢' : '🔴'}</div><div className="dash-card-value">{systemInfo.scheduler_running ? 'Active' : 'Stopped'}</div><div className="dash-card-label">Scheduler</div></div>
</>)}
</div>
{/* Auth */}
<div className="dashboard-section">
<h3>Claude Authentication</h3>
<div className="auth-panel">
{authStatus?.status === 'logged_in' ? (
<div className="auth-connected">
<span className="auth-icon">✅</span>
<div><div className="auth-title">Connected to Claude</div><div className="auth-sub">{authStatus.account || 'Authenticated'}{authStatus.auth_method && ` (${authStatus.auth_method})`}</div></div>
<button className="btn btn-secondary btn-sm" onClick={handleLogout}>Log Out</button>
</div>
) : (
<div className="auth-token-panel">
<div className="auth-token-header">
<span className="auth-icon">🔐</span>
<div><div className="auth-title">{authStatus?.has_saved_token ? 'Update Token' : 'Authenticate'}</div><div className="auth-sub">Two options:</div></div>
</div>
<div className="auth-methods">
<div className="auth-method">
<div className="auth-method-title">Option 1: Setup Token (Claude Max/Pro)</div>
<div className="auth-method-desc">Run on TrueNAS shell:</div>
<code className="auth-cmd">docker exec -it claude-persistent-agent claude setup-token</code>
<div className="auth-method-desc">Paste the <code>sk-ant-oat01-...</code> token below.</div>
</div>
<div className="auth-method">
<div className="auth-method-title">Option 2: API Key</div>
<div className="auth-method-desc">From <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noreferrer">console.anthropic.com</a></div>
</div>
</div>
<form className="auth-token-form" onSubmit={handleSubmitToken}>
<div className="auth-token-type-row">
<label><input type="radio" name="tt" value="oauth_token" checked={tokenType === 'oauth_token'} onChange={() => setTokenType('oauth_token')} /> Setup Token</label>
<label><input type="radio" name="tt" value="api_key" checked={tokenType === 'api_key'} onChange={() => setTokenType('api_key')} /> API Key</label>
</div>
<div className="auth-token-input-row">
<input type="password" placeholder={tokenType === 'oauth_token' ? 'sk-ant-oat01-...' : 'sk-ant-api03-...'} value={authToken} onChange={e => setAuthToken(e.target.value)} className="auth-token-input" autoComplete="off" />
<button type="submit" className="btn btn-primary btn-sm" disabled={tokenSubmitting || !authToken.trim()}>{tokenSubmitting ? 'Saving...' : 'Save'}</button>
</div>
</form>
{authStatus?.has_saved_token && (
<div className="auth-saved-info">Saved: {authStatus.token_type === 'api_key' ? 'API Key' : 'OAuth Token'} <button className="btn btn-secondary btn-xs" onClick={handleLogout}>Clear</button></div>
)}
</div>
)}
</div>
</div>
{/* MCP */}
<div className="dashboard-section">
<h3>MCP Servers</h3>
<div className="mcp-panel">
{mcpServers?.servers?.length > 0 ? (
<div className="mcp-list">
{mcpServers.servers.map((srv, i) => (
<div key={i} className="mcp-row">
<span className="mcp-status-dot connected"></span>
<span className="mcp-name">{srv.name || srv}</span>
<span className="mcp-details">{srv.details || srv.url || ''}</span>
<button className="btn-icon" onClick={() => handleRemoveMcp(srv.name || srv)} title="Remove">✕</button>
</div>
))}
</div>
) : <div className="mcp-empty"><p>No MCP servers configured.</p></div>}
{showAddMcp ? (
<form className="mcp-add-form" onSubmit={handleAddMcp}>
<div className="mcp-form-row">
<input type="text" placeholder="Server name" value={mcpForm.name} onChange={e => setMcpForm({...mcpForm, name: e.target.value})} required />
<select value={mcpForm.server_type} onChange={e => setMcpForm({...mcpForm, server_type: e.target.value})}><option value="sse">SSE</option><option value="stdio">Stdio</option></select>
</div>
{mcpForm.server_type === 'sse' ? <input type="url" placeholder="https://..." value={mcpForm.url} onChange={e => setMcpForm({...mcpForm, url: e.target.value})} required /> :
<input type="text" placeholder="Command" value={mcpForm.command} onChange={e => setMcpForm({...mcpForm, command: e.target.value})} required />}
<div className="mcp-form-actions"><button type="submit" className="btn btn-primary btn-sm">Add</button><button type="button" className="btn btn-secondary btn-sm" onClick={() => setShowAddMcp(false)}>Cancel</button></div>
</form>
) : <button className="btn btn-secondary btn-sm mcp-add-btn" onClick={() => setShowAddMcp(true)}>+ Add MCP Server</button>}
</div>
</div>
{/* Usage */}
<div className="dashboard-section">
<h3>Usage</h3>
<div className="usage-grid">
{usageStats ? (<>
<div className="usage-row"><span className="usage-label">Total Runs</span><span className="usage-value">{usageStats.claude_runs_total ?? ''}</span></div>
<div className="usage-row"><span className="usage-label">Sessions</span><span className="usage-value">{usageStats.session_count ?? ''}</span></div>
<div className="usage-row"><span className="usage-label">Next Reset</span><span className="usage-value">{usageStats.next_reset ? new Date(usageStats.next_reset).toLocaleDateString() : ''}</span></div>
<div className="usage-row"><span className="usage-label">Days Until Reset</span><span className="usage-value">{usageStats.days_until_reset ?? ''}</span></div>
</>) : <div className="usage-loading">Loading...</div>}
</div>
</div>
</div>
)}
</main>
</div>
);
};
const TaskRuns = ({ taskId }) => {
const [runs, setRuns] = useState([]);
useEffect(() => {
const fetch_ = async () => { try { const r = await fetch(`/api/tasks/${taskId}/runs`); setRuns(await r.json()); } catch(e) {} };
fetch_();
const i = setInterval(fetch_, 3000);
return () => clearInterval(i);
}, [taskId]);
return (
<div className="task-section">
<h3>Run History</h3>
{runs.length === 0 ? <p className="empty">No runs yet</p> : (
<div className="runs-list">
{runs.map(run => (
<div key={run.run_id} className="run-item">
<div className="run-header">
<span className={`run-status ${run.status}`}>{run.status}</span>
<span className="run-time">{new Date(run.started_at).toLocaleString()}</span>
</div>
{run.output && <details><summary>Output</summary><pre>{run.output}</pre></details>}
{run.error && <details><summary>Error</summary><pre className="error">{run.error}</pre></details>}
</div>
))}
</div>
)}
</div>
);
};
export default App;