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 (
{run.output.slice(0, 300)}
)}
{run.error && (
Configure MCP servers that Claude can use as tools. These are saved and passed to Claude via its config. Restart Claude after adding servers.
{showMcpForm && (
{lang && {lang}}
{code}
);
}
// Inline: render newlines as