diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3528204..ec71019 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,604 +3,866 @@ import './App.css'; // UUID v4 generator — works in all contexts (HTTP and HTTPS) function generateUUID() { - // Use crypto.randomUUID if available (HTTPS only) if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } - // Fallback: manual UUID v4 using crypto.getRandomValues or Math.random if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + 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)}`; } - // Last resort: Math.random-based UUID 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 [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' + const [activeView, setActiveView] = useState('chat'); - // Auth - const [authToken, setAuthToken] = useState(''); - const [tokenType, setTokenType] = useState('oauth_token'); - const [tokenSubmitting, setTokenSubmitting] = useState(false); + // Claude process status + const [claudeStatus, setClaudeStatus] = useState('unknown'); + const [claudeSessionId, setClaudeSessionId] = useState(null); - // Chat + // Chat state const [chatMessages, setChatMessages] = useState([]); const [chatInput, setChatInput] = useState(''); - const [chatSending, setChatSending] = useState(false); - const [chatSessionId, setChatSessionId] = useState(() => generateUUID()); + const [chatWaiting, setChatWaiting] = useState(false); + const [chatSessionId] = useState(() => generateUUID()); const [chatSessions, setChatSessions] = useState([]); - const [chatModel, setChatModel] = 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(); fetchSystemInfo(); fetchUsageStats(); fetchAuthStatus(); fetchMcpServers(); + fetchTasks(); + fetchMcpServers(); + fetchChatSessions(); + fetchClaudeStatus(); const interval = setInterval(() => { - fetchTasks(); fetchSystemInfo(); fetchAuthStatus(); - }, 10000); + fetchTasks(); + fetchClaudeStatus(); + }, 8000); return () => clearInterval(interval); }, []); useEffect(() => { - if (chatEndRef.current) chatEndRef.current.scrollIntoView({ behavior: 'smooth' }); + if (chatEndRef.current) { + chatEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } }, [chatMessages]); - // WebSocket for chat + // ---- WebSocket ---- const connectWs = useCallback(() => { - if (wsRef.current && wsRef.current.readyState <= 1) return; + 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/${chatSessionId}`); - ws.onopen = () => setWsConnected(true); - ws.onclose = () => setWsConnected(false); + 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) => { - 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 }]; + 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 + if (type === 'assistant') { + // Full assistant message (not streaming) + const content = data.message?.content; + let text = ''; + if (Array.isArray(content)) { + text = content.filter(b => b.type === 'text').map(b => b.text).join(''); + } else if (typeof content === 'string') { + text = content; + } + if (text) { + pendingAssistantRef.current += text; + upsertStreamingMessage(pendingAssistantRef.current, false); + } + } + + 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 = ''; } - 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 }]); + 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'); + } } }; - wsRef.current = ws; - }, [chatSessionId]); + }, []); useEffect(() => { - if (activeView === 'chat') connectWs(); + connectWs(); return () => { - if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } + if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); + if (wsRef.current) wsRef.current.close(); }; - }, [activeView, chatSessionId, connectWs]); + }, [connectWs]); - const sendChatMessage = async () => { - if (!chatInput.trim() || chatSending) return; - const msg = chatInput.trim(); + 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(''); - setChatMessages(prev => [...prev, { role: 'user', content: msg }]); - setChatSending(true); + 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({ - message: msg, model: chatModel || undefined - })); + wsRef.current.send(JSON.stringify({ action: 'start_claude' })); } 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 - }) + 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 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 = generateUUID(); - 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); + 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); + }); } }; - // 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); + const loadHistorySession = async (sid) => { + setSelectedHistorySession(sid); 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) }); + const r = await fetch(`${API}/api/chat/sessions/${sid}/messages`); 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 }); + 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) {} - setLoading(false); + } catch (e) { /* ignore */ } }; - 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); } + // ---- 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 getStatusColor = (s) => ({ running: '#3498db', completed: '#27ae60', failed: '#e74c3c' }[s] || '#95a5a6'); + 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) { + setTaskRuns(prev => ({ ...prev, [taskId]: await r.json() })); + } + } 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 ( -
-
-
-

Claude Persistent Agent

-

Chat, schedule tasks & orchestrate agents

+
+ {/* Sidebar nav */} +
+
+ + Claude Agent
-
- -
- {authStatus?.status === 'logged_in' ? ( - ● {authStatus.account || 'Authenticated'} - ) : authStatus?.has_saved_token ? ( - ● Token saved - ) : ( - setActiveView('dashboard')} style={{cursor:'pointer'}}>⚠ Not authenticated - )} -
- {systemInfo && ( -
- - {systemInfo.task_count} agents · {systemInfo.total_runs || 0} runs -
+ + {/* Claude process status pill */} +
+ + {claudeStatusLabel} + {claudeStatus === 'ready' && ( + + )} + {(claudeStatus === 'dead' || claudeStatus === 'not_started' || claudeStatus === 'unknown') && ( + )}
-
-
+ - {/* ===== CHAT VIEW ===== */} + {/* Session history (chat view only) */} {activeView === 'chat' && ( -
-
- -
- +
+
Session History
+ + {chatSessions.slice(0, 15).map(s => ( + + ))} + {chatSessions.length === 0 && ( +
No history yet
+ )} +
+ )} +
+ + {/* Main content */} +
+ + {/* ====== CHAT VIEW ====== */} + {activeView === 'chat' && ( +
+ {/* Chat header */} +
+
+

Claude Chat

+ {claudeSessionId && ( + + Session: {claudeSessionId.slice(0, 8)}… + + )}
-
- - {chatSessions.map(s => ( -
loadChatSession(s.session_id)}> -
{(s.first_message || 'Chat').substring(0, 40)}
-
{s.message_count} msgs · {new Date(s.last_message).toLocaleDateString()}
-
- ))} -
-
- - {wsConnected ? 'Connected' : 'Disconnected'} +
+ + {wsConnected ? '● Live' : '○ Connecting…'} + + {claudeStatus !== 'ready' && ( + + )}
-
-
- {chatMessages.length === 0 && ( -
-
💬
-

Chat with Claude

-

Send a message to start a conversation with the Claude Code instance running on your server.

-

Claude has access to Bash, file system, and any MCP servers you've configured.

+ + {/* Messages area */} +
+ {chatMessages.length === 0 && claudeStatus === 'ready' && ( +
+
🤖
+
Claude is ready
+
+ You're talking to a persistent Claude Code session. + It remembers context across messages.
- )} - {chatMessages.map((msg, i) => ( -
-
- {msg.role === 'user' ? '👤' : msg.role === 'error' ? '⚠️' : '🤖'} -
-
-
{msg.content}{msg.streaming ? '▊' : ''}
-
+
+ )} + {chatMessages.length === 0 && claudeStatus !== 'ready' && ( +
+
⏸️
+
Claude is not running
+
+ {claudeStatus === 'starting' + ? 'Starting the Claude process, please wait…' + : 'The Claude process needs to be started. Click "Start Claude" above.'}
- ))} -
-
-
+ {claudeStatus !== 'starting' && ( + + )} +
+ )} + + {chatMessages.map((msg, i) => ( +
+
+ {msg.role === 'user' ? '👤 You' + : msg.role === 'assistant' ? '🤖 Claude' + : msg.role === 'system' ? '⚙️ System' + : '❌ Error'} +
+
+ + {msg.streaming && } +
+
+ {msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : ''} +
+
+ ))} +
+
+ + {/* Input area */} +
+ {chatWaiting && ( +
+ ●●● Claude is thinking… +
+ )} +