860 lines
32 KiB
JavaScript
860 lines
32 KiB
JavaScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||
import './App.css';
|
||
|
||
// UUID v4 generator — works in all contexts (HTTP and HTTPS)
|
||
function generateUUID() {
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||
return crypto.randomUUID();
|
||
}
|
||
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||
const bytes = new Uint8Array(16);
|
||
crypto.getRandomValues(bytes);
|
||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
||
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
|
||
}
|
||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||
const r = (Math.random() * 16) | 0;
|
||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||
});
|
||
}
|
||
|
||
const API = '';
|
||
|
||
const App = () => {
|
||
const [activeView, setActiveView] = useState('chat');
|
||
|
||
// Claude process status
|
||
const [claudeStatus, setClaudeStatus] = useState('unknown');
|
||
const [claudeSessionId, setClaudeSessionId] = useState(null);
|
||
|
||
// Chat state
|
||
const [chatMessages, setChatMessages] = useState([]);
|
||
const [chatInput, setChatInput] = useState('');
|
||
const [chatWaiting, setChatWaiting] = useState(false);
|
||
const [chatSessionId] = useState(() => generateUUID());
|
||
const [chatSessions, setChatSessions] = useState([]);
|
||
const [selectedHistorySession, setSelectedHistorySession] = useState(null);
|
||
const chatEndRef = useRef(null);
|
||
const wsRef = useRef(null);
|
||
const [wsConnected, setWsConnected] = useState(false);
|
||
const [wsError, setWsError] = useState(null);
|
||
const pendingAssistantRef = useRef('');
|
||
const reconnectTimerRef = useRef(null);
|
||
|
||
// Tasks state
|
||
const [tasks, setTasks] = useState([]);
|
||
const [showTaskForm, setShowTaskForm] = useState(false);
|
||
const [taskForm, setTaskForm] = useState({
|
||
name: '', description: '', prompt: '', schedule_type: 'manual',
|
||
schedule_value: '', enabled: true, agent_tools: 'Bash,Read,Write,Edit',
|
||
agent_system_prompt: '', agent_permission_mode: 'auto', agent_timeout: 300
|
||
});
|
||
const [editingTaskId, setEditingTaskId] = useState(null);
|
||
const [taskRuns, setTaskRuns] = useState({});
|
||
|
||
// MCP state
|
||
const [mcpServers, setMcpServers] = useState([]);
|
||
const [showMcpForm, setShowMcpForm] = useState(false);
|
||
const [mcpForm, setMcpForm] = useState({ name: '', server_type: 'sse', url: '', command: '' });
|
||
|
||
// ---- Data fetching ----
|
||
const fetchTasks = async () => {
|
||
try {
|
||
const r = await fetch(`${API}/api/tasks`);
|
||
if (r.ok) setTasks(await r.json());
|
||
} catch (e) { /* ignore */ }
|
||
};
|
||
|
||
const fetchMcpServers = async () => {
|
||
try {
|
||
const r = await fetch(`${API}/api/mcp/servers`);
|
||
if (r.ok) setMcpServers(await r.json());
|
||
} catch (e) { /* ignore */ }
|
||
};
|
||
|
||
const fetchChatSessions = async () => {
|
||
try {
|
||
const r = await fetch(`${API}/api/chat/sessions`);
|
||
if (r.ok) setChatSessions(await r.json());
|
||
} catch (e) { /* ignore */ }
|
||
};
|
||
|
||
const fetchClaudeStatus = async () => {
|
||
try {
|
||
const r = await fetch(`${API}/api/claude/status`);
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
setClaudeStatus(d.status);
|
||
setClaudeSessionId(d.session_id);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchTasks();
|
||
fetchMcpServers();
|
||
fetchChatSessions();
|
||
fetchClaudeStatus();
|
||
const interval = setInterval(() => {
|
||
fetchTasks();
|
||
fetchClaudeStatus();
|
||
}, 8000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (chatEndRef.current) {
|
||
chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
}, [chatMessages]);
|
||
|
||
// ---- WebSocket ----
|
||
const connectWs = useCallback(() => {
|
||
if (wsRef.current && wsRef.current.readyState < 2) return; // already open or connecting
|
||
if (reconnectTimerRef.current) {
|
||
clearTimeout(reconnectTimerRef.current);
|
||
reconnectTimerRef.current = null;
|
||
}
|
||
|
||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const ws = new WebSocket(`${proto}//${window.location.host}/api/chat/ws`);
|
||
wsRef.current = ws;
|
||
|
||
ws.onopen = () => {
|
||
setWsConnected(true);
|
||
setWsError(null);
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
setWsConnected(false);
|
||
// Auto-reconnect after 3 seconds
|
||
reconnectTimerRef.current = setTimeout(connectWs, 3000);
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
setWsError('WebSocket error — reconnecting...');
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
let data;
|
||
try { data = JSON.parse(event.data); } catch (e) { return; }
|
||
|
||
const type = data.type;
|
||
|
||
if (type === 'ping') return;
|
||
|
||
if (type === 'system') {
|
||
const sub = data.subtype;
|
||
if (sub === 'connected' || sub === 'status' || sub === 'start_result' || sub === 'restart_result') {
|
||
if (data.claude_status) setClaudeStatus(data.claude_status);
|
||
if (data.claude_session_id) setClaudeSessionId(data.claude_session_id);
|
||
}
|
||
if (sub === 'process_dead') {
|
||
setClaudeStatus('dead');
|
||
setChatWaiting(false);
|
||
appendSystemMsg('⚠️ Claude process stopped. Click "Start Claude" to restart.');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (type === 'user_ack') {
|
||
// We already added the user message optimistically, nothing to do
|
||
return;
|
||
}
|
||
|
||
if (type === 'error') {
|
||
setChatWaiting(false);
|
||
appendErrorMsg(data.message || 'Unknown error');
|
||
return;
|
||
}
|
||
|
||
// Claude stream-json events
|
||
// NOTE: We intentionally skip the 'assistant' event type.
|
||
// It contains the full message text that was ALREADY streamed via
|
||
// content_block_delta events, so handling it would create a duplicate bubble.
|
||
|
||
if (type === 'content_block_delta') {
|
||
const delta = data.delta;
|
||
if (delta?.type === 'text_delta') {
|
||
pendingAssistantRef.current += delta.text || '';
|
||
upsertStreamingMessage(pendingAssistantRef.current, true);
|
||
}
|
||
}
|
||
|
||
if (type === 'content_block_stop') {
|
||
// Block done, finalize
|
||
if (pendingAssistantRef.current) {
|
||
upsertStreamingMessage(pendingAssistantRef.current, false);
|
||
}
|
||
}
|
||
|
||
if (type === 'result') {
|
||
// Turn complete
|
||
const sub = data.subtype;
|
||
if (sub === 'success' || sub === 'error_max_turns') {
|
||
if (pendingAssistantRef.current) {
|
||
upsertStreamingMessage(pendingAssistantRef.current, false);
|
||
pendingAssistantRef.current = '';
|
||
}
|
||
if (data.session_id) setClaudeSessionId(data.session_id);
|
||
setChatWaiting(false);
|
||
fetchChatSessions(); // refresh session list
|
||
}
|
||
if (sub === 'error' || sub === 'error_during_execution') {
|
||
pendingAssistantRef.current = '';
|
||
setChatWaiting(false);
|
||
appendErrorMsg(data.error || 'Claude returned an error');
|
||
}
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
connectWs();
|
||
return () => {
|
||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||
if (wsRef.current) wsRef.current.close();
|
||
};
|
||
}, [connectWs]);
|
||
|
||
const appendSystemMsg = (text) => {
|
||
setChatMessages(prev => [...prev, {
|
||
id: generateUUID(), role: 'system', content: text, timestamp: new Date().toISOString()
|
||
}]);
|
||
};
|
||
|
||
const appendErrorMsg = (text) => {
|
||
setChatMessages(prev => [...prev, {
|
||
id: generateUUID(), role: 'error', content: text, timestamp: new Date().toISOString()
|
||
}]);
|
||
};
|
||
|
||
const upsertStreamingMessage = (text, streaming) => {
|
||
setChatMessages(prev => {
|
||
const last = prev[prev.length - 1];
|
||
if (last && last.role === 'assistant' && (last.streaming || streaming)) {
|
||
return [...prev.slice(0, -1), { ...last, content: text, streaming }];
|
||
}
|
||
return [...prev, {
|
||
id: generateUUID(), role: 'assistant', content: text,
|
||
streaming, timestamp: new Date().toISOString()
|
||
}];
|
||
});
|
||
};
|
||
|
||
const sendMessage = () => {
|
||
const text = chatInput.trim();
|
||
if (!text || chatWaiting) return;
|
||
|
||
// Optimistic UI: add user message immediately
|
||
setChatMessages(prev => [...prev, {
|
||
id: generateUUID(), role: 'user', content: text, timestamp: new Date().toISOString()
|
||
}]);
|
||
setChatInput('');
|
||
setChatWaiting(true);
|
||
pendingAssistantRef.current = '';
|
||
|
||
if (!wsRef.current || wsRef.current.readyState !== 1) {
|
||
appendErrorMsg('Not connected — reconnecting...');
|
||
setChatWaiting(false);
|
||
connectWs();
|
||
return;
|
||
}
|
||
|
||
wsRef.current.send(JSON.stringify({
|
||
action: 'send',
|
||
message: text,
|
||
session_id: chatSessionId
|
||
}));
|
||
};
|
||
|
||
const handleChatKeyDown = (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendMessage();
|
||
}
|
||
};
|
||
|
||
const startClaude = () => {
|
||
setClaudeStatus('starting');
|
||
if (wsRef.current && wsRef.current.readyState === 1) {
|
||
wsRef.current.send(JSON.stringify({ action: 'start_claude' }));
|
||
} else {
|
||
fetch(`${API}/api/claude/start`, { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
setClaudeStatus(d.status);
|
||
if (d.session_id) setClaudeSessionId(d.session_id);
|
||
});
|
||
}
|
||
};
|
||
|
||
const restartClaude = () => {
|
||
setClaudeStatus('starting');
|
||
if (wsRef.current && wsRef.current.readyState === 1) {
|
||
wsRef.current.send(JSON.stringify({ action: 'restart_claude' }));
|
||
} else {
|
||
fetch(`${API}/api/claude/restart`, { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
setClaudeStatus(d.status);
|
||
if (d.session_id) setClaudeSessionId(d.session_id);
|
||
});
|
||
}
|
||
};
|
||
|
||
const loadHistorySession = async (sid) => {
|
||
setSelectedHistorySession(sid);
|
||
try {
|
||
const r = await fetch(`${API}/api/chat/sessions/${sid}/messages`);
|
||
if (r.ok) {
|
||
const msgs = await r.json();
|
||
setChatMessages(msgs.map(m => ({
|
||
id: m.id, role: m.role, content: m.content, timestamp: m.timestamp, streaming: false
|
||
})));
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
};
|
||
|
||
// ---- Tasks ----
|
||
const saveTask = async () => {
|
||
const method = editingTaskId ? 'PUT' : 'POST';
|
||
const url = editingTaskId ? `${API}/api/tasks/${editingTaskId}` : `${API}/api/tasks`;
|
||
try {
|
||
const r = await fetch(url, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(taskForm)
|
||
});
|
||
if (r.ok) {
|
||
fetchTasks();
|
||
setShowTaskForm(false);
|
||
setEditingTaskId(null);
|
||
setTaskForm({
|
||
name: '', description: '', prompt: '', schedule_type: 'manual',
|
||
schedule_value: '', enabled: true, agent_tools: 'Bash,Read,Write,Edit',
|
||
agent_system_prompt: '', agent_permission_mode: 'auto', agent_timeout: 300
|
||
});
|
||
}
|
||
} catch (e) { alert('Failed to save task'); }
|
||
};
|
||
|
||
const deleteTask = async (id) => {
|
||
if (!window.confirm('Delete this task?')) return;
|
||
await fetch(`${API}/api/tasks/${id}`, { method: 'DELETE' });
|
||
fetchTasks();
|
||
};
|
||
|
||
const runTask = async (id) => {
|
||
try {
|
||
const r = await fetch(`${API}/api/tasks/${id}/run`, { method: 'POST' });
|
||
if (r.ok) {
|
||
alert('Task started!');
|
||
fetchTasks();
|
||
}
|
||
} catch (e) { alert('Failed to run task'); }
|
||
};
|
||
|
||
const editTask = (task) => {
|
||
setEditingTaskId(task.id);
|
||
setTaskForm({ ...task });
|
||
setShowTaskForm(true);
|
||
};
|
||
|
||
const fetchTaskRuns = async (taskId) => {
|
||
try {
|
||
const r = await fetch(`${API}/api/tasks/${taskId}/runs`);
|
||
if (r.ok) {
|
||
const runs = await r.json();
|
||
setTaskRuns(prev => ({ ...prev, [taskId]: runs }));
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
};
|
||
|
||
// ---- MCP ----
|
||
const saveMcp = async () => {
|
||
try {
|
||
const r = await fetch(`${API}/api/mcp/servers`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(mcpForm)
|
||
});
|
||
if (r.ok) {
|
||
fetchMcpServers();
|
||
setShowMcpForm(false);
|
||
setMcpForm({ name: '', server_type: 'sse', url: '', command: '' });
|
||
}
|
||
} catch (e) { alert('Failed to add MCP server'); }
|
||
};
|
||
|
||
const removeMcp = async (name) => {
|
||
if (!window.confirm(`Remove MCP server "${name}"?`)) return;
|
||
await fetch(`${API}/api/mcp/servers/${name}`, { method: 'DELETE' });
|
||
fetchMcpServers();
|
||
};
|
||
|
||
// ---- Status helpers ----
|
||
const claudeStatusColor = {
|
||
ready: '#22c55e',
|
||
starting: '#f59e0b',
|
||
dead: '#ef4444',
|
||
not_started: '#6b7280',
|
||
unknown: '#6b7280'
|
||
}[claudeStatus] || '#6b7280';
|
||
|
||
const claudeStatusLabel = {
|
||
ready: 'Running',
|
||
starting: 'Starting…',
|
||
dead: 'Stopped',
|
||
not_started: 'Not Started',
|
||
unknown: 'Unknown'
|
||
}[claudeStatus] || claudeStatus;
|
||
|
||
// ---- Render ----
|
||
return (
|
||
<div className="app">
|
||
{/* Sidebar nav */}
|
||
<div className="sidebar">
|
||
<div className="sidebar-brand">
|
||
<span className="brand-icon">⚡</span>
|
||
<span className="brand-name">Claude Agent</span>
|
||
</div>
|
||
|
||
{/* Claude process status pill */}
|
||
<div className="claude-status-pill" style={{ borderColor: claudeStatusColor }}>
|
||
<span className="status-dot" style={{ background: claudeStatusColor }}></span>
|
||
<span className="status-label">{claudeStatusLabel}</span>
|
||
{claudeStatus === 'ready' && (
|
||
<button className="btn-tiny" onClick={restartClaude} title="Restart">↺</button>
|
||
)}
|
||
{(claudeStatus === 'dead' || claudeStatus === 'not_started' || claudeStatus === 'unknown') && (
|
||
<button className="btn-tiny btn-green" onClick={startClaude} title="Start">▶</button>
|
||
)}
|
||
</div>
|
||
|
||
<nav className="sidebar-nav">
|
||
<button className={`nav-item ${activeView === 'chat' ? 'active' : ''}`}
|
||
onClick={() => setActiveView('chat')}>
|
||
<span className="nav-icon">💬</span> Chat
|
||
</button>
|
||
<button className={`nav-item ${activeView === 'tasks' ? 'active' : ''}`}
|
||
onClick={() => { setActiveView('tasks'); fetchTasks(); }}>
|
||
<span className="nav-icon">📋</span> Tasks
|
||
</button>
|
||
<button className={`nav-item ${activeView === 'mcp' ? 'active' : ''}`}
|
||
onClick={() => { setActiveView('mcp'); fetchMcpServers(); }}>
|
||
<span className="nav-icon">🔌</span> MCP Servers
|
||
</button>
|
||
</nav>
|
||
|
||
{/* Session history (chat view only) */}
|
||
{activeView === 'chat' && (
|
||
<div className="session-history">
|
||
<div className="session-history-title">Session History</div>
|
||
<button className="session-new-btn" onClick={() => {
|
||
setChatMessages([]);
|
||
setSelectedHistorySession(null);
|
||
pendingAssistantRef.current = '';
|
||
}}>+ New Chat</button>
|
||
{chatSessions.slice(0, 15).map(s => (
|
||
<button key={s.session_id}
|
||
className={`session-item ${selectedHistorySession === s.session_id ? 'active' : ''}`}
|
||
onClick={() => loadHistorySession(s.session_id)}>
|
||
<span className="session-item-date">
|
||
{new Date(s.last_active).toLocaleDateString()}
|
||
</span>
|
||
<span className="session-item-count">{s.message_count} msgs</span>
|
||
</button>
|
||
))}
|
||
{chatSessions.length === 0 && (
|
||
<div className="session-empty">No history yet</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Main content */}
|
||
<div className="main-content">
|
||
|
||
{/* ====== CHAT VIEW ====== */}
|
||
{activeView === 'chat' && (
|
||
<div className="chat-container">
|
||
{/* Chat header */}
|
||
<div className="chat-header">
|
||
<div className="chat-header-info">
|
||
<h2>Claude Chat</h2>
|
||
{claudeSessionId && (
|
||
<span className="session-id-badge" title={claudeSessionId}>
|
||
Session: {claudeSessionId.slice(0, 8)}…
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="chat-header-status">
|
||
<span className={`ws-badge ${wsConnected ? 'connected' : 'disconnected'}`}>
|
||
{wsConnected ? '● Live' : '○ Connecting…'}
|
||
</span>
|
||
{claudeStatus !== 'ready' && (
|
||
<button className="btn btn-primary btn-sm" onClick={startClaude}>
|
||
{claudeStatus === 'starting' ? 'Starting…' : 'Start Claude'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Messages area */}
|
||
<div className="chat-messages">
|
||
{chatMessages.length === 0 && claudeStatus === 'ready' && (
|
||
<div className="chat-empty">
|
||
<div className="chat-empty-icon">🤖</div>
|
||
<div className="chat-empty-title">Claude is ready</div>
|
||
<div className="chat-empty-sub">
|
||
You're talking to a persistent Claude Code session.
|
||
It remembers context across messages.
|
||
</div>
|
||
</div>
|
||
)}
|
||
{chatMessages.length === 0 && claudeStatus !== 'ready' && (
|
||
<div className="chat-empty">
|
||
<div className="chat-empty-icon">⏸️</div>
|
||
<div className="chat-empty-title">Claude is not running</div>
|
||
<div className="chat-empty-sub">
|
||
{claudeStatus === 'starting'
|
||
? 'Starting the Claude process, please wait…'
|
||
: 'The Claude process needs to be started. Click "Start Claude" above.'}
|
||
</div>
|
||
{claudeStatus !== 'starting' && (
|
||
<button className="btn btn-primary mt-2" onClick={startClaude}>
|
||
Start Claude
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{chatMessages.map((msg, i) => (
|
||
<div key={msg.id || i} className={`chat-msg chat-msg-${msg.role}`}>
|
||
<div className="msg-role">
|
||
{msg.role === 'user' ? '👤 You'
|
||
: msg.role === 'assistant' ? '🤖 Claude'
|
||
: msg.role === 'system' ? '⚙️ System'
|
||
: '❌ Error'}
|
||
</div>
|
||
<div className="msg-content">
|
||
<MessageContent content={msg.content} />
|
||
{msg.streaming && <span className="typing-cursor">▋</span>}
|
||
</div>
|
||
<div className="msg-time">
|
||
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : ''}
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div ref={chatEndRef} />
|
||
</div>
|
||
|
||
{/* Input area */}
|
||
<div className="chat-input-area">
|
||
{chatWaiting && (
|
||
<div className="thinking-bar">
|
||
<span className="thinking-dots">●●●</span> Claude is thinking…
|
||
</div>
|
||
)}
|
||
<div className="chat-input-row">
|
||
<textarea
|
||
className="chat-textarea"
|
||
value={chatInput}
|
||
onChange={e => setChatInput(e.target.value)}
|
||
onKeyDown={handleChatKeyDown}
|
||
placeholder={
|
||
claudeStatus !== 'ready'
|
||
? 'Start Claude to begin chatting…'
|
||
: 'Type a message… (Enter to send, Shift+Enter for newline)'
|
||
}
|
||
disabled={chatWaiting || claudeStatus !== 'ready'}
|
||
rows={3}
|
||
/>
|
||
<button
|
||
className="chat-send-btn"
|
||
onClick={sendMessage}
|
||
disabled={chatWaiting || !chatInput.trim() || claudeStatus !== 'ready'}
|
||
>
|
||
{chatWaiting ? '…' : '➤'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ====== TASKS VIEW ====== */}
|
||
{activeView === 'tasks' && (
|
||
<div className="view-container">
|
||
<div className="view-header">
|
||
<h2>Scheduled Tasks</h2>
|
||
<button className="btn btn-primary" onClick={() => {
|
||
setShowTaskForm(true);
|
||
setEditingTaskId(null);
|
||
setTaskForm({
|
||
name: '', description: '', prompt: '', schedule_type: 'manual',
|
||
schedule_value: '', enabled: true, agent_tools: 'Bash,Read,Write,Edit',
|
||
agent_system_prompt: '', agent_permission_mode: 'auto', agent_timeout: 300
|
||
});
|
||
}}>+ New Task</button>
|
||
</div>
|
||
|
||
{showTaskForm && (
|
||
<div className="form-card">
|
||
<h3>{editingTaskId ? 'Edit Task' : 'New Task'}</h3>
|
||
<div className="form-grid">
|
||
<div className="form-field">
|
||
<label>Name *</label>
|
||
<input value={taskForm.name}
|
||
onChange={e => setTaskForm(p => ({ ...p, name: e.target.value }))}
|
||
placeholder="My Task" />
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Schedule</label>
|
||
<select value={taskForm.schedule_type}
|
||
onChange={e => setTaskForm(p => ({ ...p, schedule_type: e.target.value }))}>
|
||
<option value="manual">Manual only</option>
|
||
<option value="recurring">Recurring (cron)</option>
|
||
</select>
|
||
</div>
|
||
{taskForm.schedule_type === 'recurring' && (
|
||
<div className="form-field">
|
||
<label>Cron Expression</label>
|
||
<input value={taskForm.schedule_value}
|
||
onChange={e => setTaskForm(p => ({ ...p, schedule_value: e.target.value }))}
|
||
placeholder="0 9 * * 1-5 (weekdays at 9am)" />
|
||
</div>
|
||
)}
|
||
<div className="form-field form-field-full">
|
||
<label>Prompt *</label>
|
||
<textarea value={taskForm.prompt}
|
||
onChange={e => setTaskForm(p => ({ ...p, prompt: e.target.value }))}
|
||
placeholder="What should Claude do?" rows={4} />
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Allowed Tools</label>
|
||
<input value={taskForm.agent_tools}
|
||
onChange={e => setTaskForm(p => ({ ...p, agent_tools: e.target.value }))}
|
||
placeholder="Bash,Read,Write,Edit" />
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Permission Mode</label>
|
||
<select value={taskForm.agent_permission_mode}
|
||
onChange={e => setTaskForm(p => ({ ...p, agent_permission_mode: e.target.value }))}>
|
||
<option value="auto">Auto</option>
|
||
<option value="acceptEdits">Accept Edits</option>
|
||
<option value="plan">Plan only</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Timeout (seconds)</label>
|
||
<input type="number" value={taskForm.agent_timeout}
|
||
onChange={e => setTaskForm(p => ({ ...p, agent_timeout: parseInt(e.target.value) || 300 }))} />
|
||
</div>
|
||
<div className="form-field form-field-full">
|
||
<label>System Prompt (optional)</label>
|
||
<textarea value={taskForm.agent_system_prompt}
|
||
onChange={e => setTaskForm(p => ({ ...p, agent_system_prompt: e.target.value }))}
|
||
placeholder="Custom instructions for this task..." rows={2} />
|
||
</div>
|
||
</div>
|
||
<div className="form-actions">
|
||
<button className="btn btn-primary" onClick={saveTask}
|
||
disabled={!taskForm.name || !taskForm.prompt}>
|
||
{editingTaskId ? 'Update Task' : 'Create Task'}
|
||
</button>
|
||
<button className="btn btn-secondary" onClick={() => setShowTaskForm(false)}>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="task-list">
|
||
{tasks.length === 0 && (
|
||
<div className="empty-state">No tasks yet. Create one to get started.</div>
|
||
)}
|
||
{tasks.map(task => (
|
||
<div key={task.id} className="task-card">
|
||
<div className="task-card-header">
|
||
<div className="task-info">
|
||
<span className="task-name">{task.name}</span>
|
||
<span className={`task-status-badge status-${task.status}`}>
|
||
{task.status}
|
||
</span>
|
||
{task.schedule_type === 'recurring' && task.schedule_value && (
|
||
<span className="task-cron-badge">⏱ {task.schedule_value}</span>
|
||
)}
|
||
</div>
|
||
<div className="task-actions">
|
||
<button className="btn btn-sm btn-success" onClick={() => runTask(task.id)}
|
||
disabled={task.status === 'running'}>
|
||
▶ Run
|
||
</button>
|
||
<button className="btn btn-sm btn-secondary" onClick={() => editTask(task)}>
|
||
Edit
|
||
</button>
|
||
<button className="btn btn-sm btn-secondary"
|
||
onClick={() => fetchTaskRuns(task.id)}>
|
||
Logs
|
||
</button>
|
||
<button className="btn btn-sm btn-danger" onClick={() => deleteTask(task.id)}>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{task.description && (
|
||
<div className="task-desc">{task.description}</div>
|
||
)}
|
||
<div className="task-prompt-preview">{task.prompt.slice(0, 120)}{task.prompt.length > 120 ? '…' : ''}</div>
|
||
|
||
{taskRuns[task.id] && (
|
||
<div className="task-runs">
|
||
<div className="task-runs-title">Recent Runs</div>
|
||
{taskRuns[task.id].slice(0, 5).map(run => (
|
||
<div key={run.run_id} className={`run-item run-${run.status}`}>
|
||
<span className="run-status">{run.status}</span>
|
||
<span className="run-time">{new Date(run.started_at).toLocaleString()}</span>
|
||
{run.output && (
|
||
<pre className="run-output">{run.output.slice(0, 300)}</pre>
|
||
)}
|
||
{run.error && (
|
||
<div className="run-error">{run.error}</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ====== MCP VIEW ====== */}
|
||
{activeView === 'mcp' && (
|
||
<div className="view-container">
|
||
<div className="view-header">
|
||
<h2>MCP Servers</h2>
|
||
<button className="btn btn-primary" onClick={() => setShowMcpForm(true)}>
|
||
+ Add Server
|
||
</button>
|
||
</div>
|
||
<p className="view-desc">
|
||
Configure MCP servers that Claude can use as tools. These are saved and
|
||
passed to Claude via its config. Restart Claude after adding servers.
|
||
</p>
|
||
|
||
{showMcpForm && (
|
||
<div className="form-card">
|
||
<h3>Add MCP Server</h3>
|
||
<div className="form-grid">
|
||
<div className="form-field">
|
||
<label>Name</label>
|
||
<input value={mcpForm.name}
|
||
onChange={e => setMcpForm(p => ({ ...p, name: e.target.value }))}
|
||
placeholder="my-server" />
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Type</label>
|
||
<select value={mcpForm.server_type}
|
||
onChange={e => setMcpForm(p => ({ ...p, server_type: e.target.value }))}>
|
||
<option value="sse">SSE (HTTP)</option>
|
||
<option value="stdio">stdio (command)</option>
|
||
</select>
|
||
</div>
|
||
{mcpForm.server_type === 'sse' && (
|
||
<div className="form-field form-field-full">
|
||
<label>URL</label>
|
||
<input value={mcpForm.url}
|
||
onChange={e => setMcpForm(p => ({ ...p, url: e.target.value }))}
|
||
placeholder="https://mcp.example.com/sse" />
|
||
</div>
|
||
)}
|
||
{mcpForm.server_type === 'stdio' && (
|
||
<div className="form-field form-field-full">
|
||
<label>Command</label>
|
||
<input value={mcpForm.command}
|
||
onChange={e => setMcpForm(p => ({ ...p, command: e.target.value }))}
|
||
placeholder="npx -y @my/mcp-server" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="form-actions">
|
||
<button className="btn btn-primary" onClick={saveMcp}
|
||
disabled={!mcpForm.name}>
|
||
Add Server
|
||
</button>
|
||
<button className="btn btn-secondary" onClick={() => setShowMcpForm(false)}>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mcp-list">
|
||
{mcpServers.length === 0 && (
|
||
<div className="empty-state">No MCP servers configured.</div>
|
||
)}
|
||
{mcpServers.map(s => (
|
||
<div key={s.name} className="mcp-card">
|
||
<div className="mcp-card-header">
|
||
<div>
|
||
<span className="mcp-name">{s.name}</span>
|
||
<span className="mcp-type-badge">{s.type || 'sse'}</span>
|
||
</div>
|
||
<button className="btn btn-sm btn-danger" onClick={() => removeMcp(s.name)}>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
<div className="mcp-url">{s.url || s.command || ''}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ---- MessageContent: render text with basic code block support ----
|
||
const MessageContent = ({ content }) => {
|
||
if (!content) return null;
|
||
|
||
// Split on code fences
|
||
const parts = content.split(/(```[\s\S]*?```)/g);
|
||
return (
|
||
<div className="msg-text">
|
||
{parts.map((part, i) => {
|
||
if (part.startsWith('```')) {
|
||
const lines = part.split('\n');
|
||
const lang = lines[0].slice(3).trim();
|
||
const code = lines.slice(1, -1).join('\n');
|
||
return (
|
||
<pre key={i} className="code-block">
|
||
{lang && <span className="code-lang">{lang}</span>}
|
||
<code>{code}</code>
|
||
</pre>
|
||
);
|
||
}
|
||
// Inline: render newlines as <br>
|
||
return (
|
||
<span key={i}>
|
||
{part.split('\n').map((line, j) => (
|
||
<React.Fragment key={j}>
|
||
{line}
|
||
{j < part.split('\n').length - 1 && <br />}
|
||
</React.Fragment>
|
||
))}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default App;
|