diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index eb00f29..35ccc7a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
import './App.css';
const App = () => {
@@ -6,12 +6,10 @@ const App = () => {
const [selectedTask, setSelectedTask] = useState(null);
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({
- name: '',
- description: '',
- prompt: '',
- schedule_type: 'manual',
- schedule_value: '',
- enabled: true
+ 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);
@@ -20,403 +18,296 @@ const App = () => {
const [showAddMcp, setShowAddMcp] = useState(false);
const [mcpForm, setMcpForm] = useState({ name: '', server_type: 'sse', url: '', command: '' });
const [loading, setLoading] = useState(false);
- const [activeView, setActiveView] = useState('tasks');
+ const [activeView, setActiveView] = useState('chat'); // 'chat' | 'tasks' | 'dashboard'
- // Auth token state
+ // 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();
+ fetchTasks(); fetchSystemInfo(); fetchUsageStats(); fetchAuthStatus(); fetchMcpServers();
const interval = setInterval(() => {
- fetchTasks();
- fetchSystemInfo();
- fetchUsageStats();
- fetchAuthStatus();
- fetchMcpServers();
+ fetchTasks(); fetchSystemInfo(); fetchAuthStatus();
}, 10000);
return () => clearInterval(interval);
}, []);
- const fetchTasks = async () => {
- try {
- const response = await fetch('/api/tasks');
- const data = await response.json();
- setTasks(data);
- } catch (error) {
- console.error('Error fetching tasks:', error);
+ 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 fetchSystemInfo = async () => {
+ 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/system/info');
+ const response = await fetch(`/api/chat/history/${sid}`);
const data = await response.json();
- setSystemInfo(data);
- } catch (error) {
- console.error('Error fetching system info:', error);
+ setChatMessages(data.map(m => ({ role: m.role, content: m.content })));
+ } catch (e) {
+ console.error('Error loading chat session:', e);
}
};
- const fetchUsageStats = async () => {
- try {
- const response = await fetch('/api/system/usage');
- const data = await response.json();
- setUsageStats(data);
- } catch (error) {
- console.error('Error fetching usage stats:', error);
- }
- };
-
- const fetchAuthStatus = async () => {
- try {
- const response = await fetch('/api/auth/status');
- const data = await response.json();
- setAuthStatus(data);
- } catch (error) {
- console.error('Error fetching auth status:', error);
- }
- };
+ // 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 response = await fetch('/api/auth/token', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ 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 response.json();
- if (data.status === 'logged_in') {
- setAuthToken('');
- fetchAuthStatus();
- } else if (data.status === 'token_saved') {
- setAuthToken('');
- fetchAuthStatus();
- alert(data.message);
- } else {
- alert(data.message || 'Token submission failed');
- }
- } catch (error) {
- console.error('Error submitting token:', error);
- alert('Failed to submit token');
- }
+ 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 () => {
- try {
- await fetch('/api/auth/logout', { method: 'POST' });
- fetchAuthStatus();
- } catch (error) {
- console.error('Error logging out:', error);
- }
- };
-
- const fetchMcpServers = async () => {
- try {
- const response = await fetch('/api/mcp/servers');
- const data = await response.json();
- setMcpServers(data);
- } catch (error) {
- console.error('Error fetching MCP servers:', error);
- }
- };
+ const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }); fetchAuthStatus(); };
const handleAddMcp = async (e) => {
e.preventDefault();
- try {
- const response = await fetch('/api/mcp/servers', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(mcpForm)
- });
- const data = await response.json();
- if (data.status === 'ok') {
- setShowAddMcp(false);
- setMcpForm({ name: '', server_type: 'sse', url: '', command: '' });
- fetchMcpServers();
- } else {
- alert(data.message || 'Failed to add MCP server');
- }
- } catch (error) {
- console.error('Error adding MCP server:', error);
- }
- };
-
- const handleRemoveMcp = async (name) => {
- if (!confirm(`Remove MCP server "${name}"?`)) return;
- try {
- await fetch(`/api/mcp/servers/${name}`, { method: 'DELETE' });
- fetchMcpServers();
- } catch (error) {
- console.error('Error removing MCP server:', error);
- }
+ 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);
+ e.preventDefault(); setLoading(true);
try {
- const response = await fetch('/api/tasks', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(formData)
- });
- if (response.ok) {
- await fetchTasks();
- setShowForm(false);
- setFormData({ name: '', description: '', prompt: '', schedule_type: 'manual', schedule_value: '', enabled: true });
+ 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 (error) {
- console.error('Error creating task:', error);
- }
+ } catch(e) {}
setLoading(false);
};
- const handleRunTask = async (taskId) => {
- try {
- await fetch(`/api/tasks/${taskId}/run`, { method: 'POST' });
- await fetchTasks();
- } catch (error) {
- console.error('Error running task:', error);
- }
- };
-
+ const handleRunTask = async (taskId) => { await fetch(`/api/tasks/${taskId}/run`, { method: 'POST' }); await fetchTasks(); };
const handleDeleteTask = async (taskId) => {
- if (confirm('Delete this task?')) {
- try {
- await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' });
- await fetchTasks();
- setSelectedTask(null);
- } catch (error) {
- console.error('Error deleting task:', error);
- }
- }
+ if (confirm('Delete this task?')) { await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' }); await fetchTasks(); setSelectedTask(null); }
};
- const getStatusColor = (status) => {
- switch (status) {
- case 'running': return '#3498db';
- case 'completed': return '#27ae60';
- case 'failed': return '#e74c3c';
- default: return '#95a5a6';
- }
- };
+ const getStatusColor = (s) => ({ running: '#3498db', completed: '#27ae60', failed: '#e74c3c' }[s] || '#95a5a6');
return (
Claude Persistent Agent
-
Scheduled task management & Claude Code runner
+
Chat, schedule tasks & orchestrate agents
{authStatus?.status === 'logged_in' ? (
-
- ● {authStatus.account || `Authenticated (${authStatus.auth_method || 'token'})`}
-
+ ● {authStatus.account || 'Authenticated'}
) : authStatus?.has_saved_token ? (
-
- ● Token saved ({authStatus.token_type === 'api_key' ? 'API Key' : 'OAuth'})
-
+ ● Token saved
) : (
- setActiveView('dashboard')} style={{cursor:'pointer'}}>
- ⚠ Not authenticated
-
+ setActiveView('dashboard')} style={{cursor:'pointer'}}>⚠ Not authenticated
)}
{systemInfo && (
- {systemInfo.task_count} tasks · {systemInfo.total_runs || 0} runs
+ {systemInfo.task_count} agents · {systemInfo.total_runs || 0} runs
)}
- {activeView === 'dashboard' && (
-
-
System Dashboard
-
- {systemInfo && (
- <>
-
📋
{systemInfo.task_count}
Total Tasks
-
✅
{systemInfo.completed_runs || 0}
Completed Runs
-
❌
{systemInfo.failed_runs || 0}
Failed Runs
-
⚡
{systemInfo.running_runs || 0}
Currently Running
-
🔄
{systemInfo.total_runs || 0}
Total Runs
-
{systemInfo.scheduler_running ? '🟢' : '🔴'}
{systemInfo.scheduler_running ? 'Active' : 'Stopped'}
Scheduler
- >
- )}
-
- {/* Auth Section */}
-
-
Claude Authentication
-
- {authStatus?.status === 'logged_in' ? (
-
-
✅
-
-
Connected to Claude
-
{authStatus.account || 'Authenticated'}{authStatus.auth_method && ` (${authStatus.auth_method})`}
-
-
+ {/* ===== CHAT VIEW ===== */}
+ {activeView === 'chat' && (
+
+
+
+
+
+
+
+
+ {chatSessions.map(s => (
+
loadChatSession(s.session_id)}>
+
{(s.first_message || 'Chat').substring(0, 40)}
+
{s.message_count} msgs · {new Date(s.last_message).toLocaleDateString()}
- ) : (
-
-
-
🔐
-
-
{authStatus?.has_saved_token ? 'Token Saved — Update or Replace' : 'Authenticate with Token'}
-
Two options to authenticate:
-
-
-
-
-
-
Option 1: Setup Token (Claude Max/Pro)
-
Run this on TrueNAS to generate a long-lived token:
-
docker exec -it claude-persistent-agent claude setup-token
-
Then paste the token (starts with sk-ant-oat) below.
-
-
-
Option 2: API Key (Console billing)
-
-
-
-
-
-
- {authStatus?.has_saved_token && (
-
- Saved: {authStatus.token_type === 'api_key' ? 'API Key' : 'OAuth Token'}
-
-
- )}
-
- )}
+ ))}
+
+
+
+ {wsConnected ? 'Connected' : 'Disconnected'}
-
- {/* MCP Section */}
-
-
MCP Servers
-
- {mcpServers?.servers?.length > 0 ? (
-
- {mcpServers.servers.map((srv, i) => (
-
-
- {srv.name || srv}
- {srv.details || srv.url || srv.type || ''}
-
-
- ))}
-
- ) : (
-
-
No MCP servers configured.
- {mcpServers?.raw &&
{mcpServers.raw}}
+
+
+ {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.
)}
- {showAddMcp ? (
-
) : (
-
Select a task to view details
+
Select an agent task to view details
Or create a new one to get started
)}
>)}
+
+ {/* ===== DASHBOARD VIEW ===== */}
+ {activeView === 'dashboard' && (
+
+
System Dashboard
+
+ {systemInfo && (<>
+
📋
{systemInfo.task_count}
Agent Tasks
+
✅
{systemInfo.completed_runs || 0}
Completed
+
❌
{systemInfo.failed_runs || 0}
Failed
+
⚡
{systemInfo.running_runs || 0}
Running
+
💬
{systemInfo.active_chat_sessions || 0}
Active Chats
+
{systemInfo.scheduler_running ? '🟢' : '🔴'}
{systemInfo.scheduler_running ? 'Active' : 'Stopped'}
Scheduler
+ >)}
+
+
+ {/* Auth */}
+
+
Claude Authentication
+
+ {authStatus?.status === 'logged_in' ? (
+
+
✅
+
Connected to Claude
{authStatus.account || 'Authenticated'}{authStatus.auth_method && ` (${authStatus.auth_method})`}
+
+
+ ) : (
+
+
+
🔐
+
{authStatus?.has_saved_token ? 'Update Token' : 'Authenticate'}
Two options:
+
+
+
+
Option 1: Setup Token (Claude Max/Pro)
+
Run on TrueNAS shell:
+
docker exec -it claude-persistent-agent claude setup-token
+
Paste the sk-ant-oat01-... token below.
+
+
+
+
+ {authStatus?.has_saved_token && (
+
Saved: {authStatus.token_type === 'api_key' ? 'API Key' : 'OAuth Token'}
+ )}
+
+ )}
+
+
+
+ {/* MCP */}
+
+
MCP Servers
+
+ {mcpServers?.servers?.length > 0 ? (
+
+ {mcpServers.servers.map((srv, i) => (
+
+
+ {srv.name || srv}
+ {srv.details || srv.url || ''}
+
+
+ ))}
+
+ ) :
No MCP servers configured.
}
+ {showAddMcp ? (
+
+ ) :
}
+
+
+
+ {/* Usage */}
+
+
Usage
+
+ {usageStats ? (<>
+
Total Runs{usageStats.claude_runs_total ?? '—'}
+
Sessions{usageStats.session_count ?? '—'}
+
Next Reset{usageStats.next_reset ? new Date(usageStats.next_reset).toLocaleDateString() : '—'}
+
Days Until Reset{usageStats.days_until_reset ?? '—'}
+ >) :
Loading...
}
+
+
+
+ )}
);
@@ -515,30 +555,19 @@ const App = () => {
const TaskRuns = ({ taskId }) => {
const [runs, setRuns] = useState([]);
-
useEffect(() => {
- const fetchRuns = async () => {
- try {
- const response = await fetch(`/api/tasks/${taskId}/runs`);
- const data = await response.json();
- setRuns(data);
- } catch (error) {
- console.error('Error fetching runs:', error);
- }
- };
- fetchRuns();
- const interval = setInterval(fetchRuns, 3000);
- return () => clearInterval(interval);
+ 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 (
Run History
- {runs.length === 0 ? (
-
No runs yet
- ) : (
+ {runs.length === 0 ?
No runs yet
: (
- {runs.map((run) => (
+ {runs.map(run => (