2026-04-05 00:26:21 -04:00
|
|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
2026-04-04 22:40:51 -04:00
|
|
|
|
import './App.css';
|
|
|
|
|
|
|
2026-04-05 00:32:24 -04:00
|
|
|
|
// 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);
|
2026-04-05 10:49:04 -04:00
|
|
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
|
|
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
2026-04-05 00:32:24 -04:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
const API = '';
|
|
|
|
|
|
|
2026-04-04 22:40:51 -04:00
|
|
|
|
const App = () => {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
const [activeView, setActiveView] = useState('chat');
|
2026-04-05 00:13:37 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
// Claude process status
|
|
|
|
|
|
const [claudeStatus, setClaudeStatus] = useState('unknown');
|
|
|
|
|
|
const [claudeSessionId, setClaudeSessionId] = useState(null);
|
2026-04-04 22:40:51 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
// Chat state
|
2026-04-05 00:26:21 -04:00
|
|
|
|
const [chatMessages, setChatMessages] = useState([]);
|
|
|
|
|
|
const [chatInput, setChatInput] = useState('');
|
2026-04-05 10:49:04 -04:00
|
|
|
|
const [chatWaiting, setChatWaiting] = useState(false);
|
|
|
|
|
|
const [chatSessionId] = useState(() => generateUUID());
|
2026-04-05 00:26:21 -04:00
|
|
|
|
const [chatSessions, setChatSessions] = useState([]);
|
2026-04-05 10:49:04 -04:00
|
|
|
|
const [selectedHistorySession, setSelectedHistorySession] = useState(null);
|
2026-04-05 00:26:21 -04:00
|
|
|
|
const chatEndRef = useRef(null);
|
|
|
|
|
|
const wsRef = useRef(null);
|
|
|
|
|
|
const [wsConnected, setWsConnected] = useState(false);
|
2026-04-05 10:49:04 -04:00
|
|
|
|
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 */ }
|
|
|
|
|
|
};
|
2026-04-05 00:26:21 -04:00
|
|
|
|
|
2026-04-04 22:40:51 -04:00
|
|
|
|
useEffect(() => {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
fetchTasks();
|
|
|
|
|
|
fetchMcpServers();
|
|
|
|
|
|
fetchChatSessions();
|
|
|
|
|
|
fetchClaudeStatus();
|
2026-04-04 22:40:51 -04:00
|
|
|
|
const interval = setInterval(() => {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
fetchTasks();
|
|
|
|
|
|
fetchClaudeStatus();
|
|
|
|
|
|
}, 8000);
|
2026-04-04 22:40:51 -04:00
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-04-05 00:26:21 -04:00
|
|
|
|
useEffect(() => {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
if (chatEndRef.current) {
|
|
|
|
|
|
chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
|
|
|
|
}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
}, [chatMessages]);
|
2026-04-04 22:40:51 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
// ---- WebSocket ----
|
2026-04-05 00:26:21 -04:00
|
|
|
|
const connectWs = useCallback(() => {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
if (wsRef.current && wsRef.current.readyState < 2) return; // already open or connecting
|
|
|
|
|
|
if (reconnectTimerRef.current) {
|
|
|
|
|
|
clearTimeout(reconnectTimerRef.current);
|
|
|
|
|
|
reconnectTimerRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 00:26:21 -04:00
|
|
|
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
2026-04-05 10:49:04 -04:00
|
|
|
|
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...');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-05 00:26:21 -04:00
|
|
|
|
ws.onmessage = (event) => {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
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
|
2026-04-05 11:45:43 -04:00
|
|
|
|
// 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.
|
2026-04-05 10:49:04 -04:00
|
|
|
|
|
|
|
|
|
|
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 = '';
|
2026-04-05 00:26:21 -04:00
|
|
|
|
}
|
2026-04-05 10:49:04 -04:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-04-05 10:49:04 -04:00
|
|
|
|
}, []);
|
2026-04-05 00:26:21 -04:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
connectWs();
|
2026-04-05 00:26:21 -04:00
|
|
|
|
return () => {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
|
|
|
|
|
if (wsRef.current) wsRef.current.close();
|
2026-04-05 00:26:21 -04:00
|
|
|
|
};
|
2026-04-05 10:49:04 -04:00
|
|
|
|
}, [connectWs]);
|
|
|
|
|
|
|
|
|
|
|
|
const appendSystemMsg = (text) => {
|
|
|
|
|
|
setChatMessages(prev => [...prev, {
|
|
|
|
|
|
id: generateUUID(), role: 'system', content: text, timestamp: new Date().toISOString()
|
|
|
|
|
|
}]);
|
|
|
|
|
|
};
|
2026-04-05 00:26:21 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
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()
|
|
|
|
|
|
}]);
|
2026-04-05 00:26:21 -04:00
|
|
|
|
setChatInput('');
|
2026-04-05 10:49:04 -04:00
|
|
|
|
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
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
2026-04-05 00:26:21 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
const handleChatKeyDown = (e) => {
|
|
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
sendMessage();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const startClaude = () => {
|
|
|
|
|
|
setClaudeStatus('starting');
|
2026-04-05 00:26:21 -04:00
|
|
|
|
if (wsRef.current && wsRef.current.readyState === 1) {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
wsRef.current.send(JSON.stringify({ action: 'start_claude' }));
|
2026-04-05 00:26:21 -04:00
|
|
|
|
} else {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
fetch(`${API}/api/claude/start`, { method: 'POST' })
|
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
|
.then(d => {
|
|
|
|
|
|
setClaudeStatus(d.status);
|
|
|
|
|
|
if (d.session_id) setClaudeSessionId(d.session_id);
|
2026-04-05 00:26:21 -04:00
|
|
|
|
});
|
2026-04-04 22:40:51 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-04 22:46:16 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
const loadHistorySession = async (sid) => {
|
|
|
|
|
|
setSelectedHistorySession(sid);
|
2026-04-04 22:51:39 -04:00
|
|
|
|
try {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
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 */ }
|
2026-04-04 22:51:39 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
// ---- Tasks ----
|
|
|
|
|
|
const saveTask = async () => {
|
|
|
|
|
|
const method = editingTaskId ? 'PUT' : 'POST';
|
|
|
|
|
|
const url = editingTaskId ? `${API}/api/tasks/${editingTaskId}` : `${API}/api/tasks`;
|
2026-04-04 23:54:24 -04:00
|
|
|
|
try {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
const r = await fetch(url, {
|
|
|
|
|
|
method,
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(taskForm)
|
2026-04-04 23:54:24 -04:00
|
|
|
|
});
|
2026-04-05 10:49:04 -04:00
|
|
|
|
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'); }
|
2026-04-04 22:51:39 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
const deleteTask = async (id) => {
|
|
|
|
|
|
if (!window.confirm('Delete this task?')) return;
|
|
|
|
|
|
await fetch(`${API}/api/tasks/${id}`, { method: 'DELETE' });
|
|
|
|
|
|
fetchTasks();
|
|
|
|
|
|
};
|
2026-04-04 23:50:16 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
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'); }
|
2026-04-04 23:50:16 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
const editTask = (task) => {
|
|
|
|
|
|
setEditingTaskId(task.id);
|
|
|
|
|
|
setTaskForm({ ...task });
|
|
|
|
|
|
setShowTaskForm(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const fetchTaskRuns = async (taskId) => {
|
2026-04-04 22:40:51 -04:00
|
|
|
|
try {
|
2026-04-05 10:49:04 -04:00
|
|
|
|
const r = await fetch(`${API}/api/tasks/${taskId}/runs`);
|
2026-04-05 00:26:21 -04:00
|
|
|
|
if (r.ok) {
|
2026-04-05 10:51:18 -04:00
|
|
|
|
const runs = await r.json();
|
|
|
|
|
|
setTaskRuns(prev => ({ ...prev, [taskId]: runs }));
|
2026-04-04 22:40:51 -04:00
|
|
|
|
}
|
2026-04-05 10:49:04 -04:00
|
|
|
|
} catch (e) { /* ignore */ }
|
2026-04-04 22:40:51 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
// ---- 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'); }
|
2026-04-04 22:40:51 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
const removeMcp = async (name) => {
|
|
|
|
|
|
if (!window.confirm(`Remove MCP server "${name}"?`)) return;
|
|
|
|
|
|
await fetch(`${API}/api/mcp/servers/${name}`, { method: 'DELETE' });
|
|
|
|
|
|
fetchMcpServers();
|
|
|
|
|
|
};
|
2026-04-04 22:40:51 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
// ---- 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 ----
|
2026-04-04 22:40:51 -04:00
|
|
|
|
return (
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<div className="app">
|
|
|
|
|
|
{/* Sidebar nav */}
|
|
|
|
|
|
<div className="sidebar">
|
|
|
|
|
|
<div className="sidebar-brand">
|
|
|
|
|
|
<span className="brand-icon">⚡</span>
|
|
|
|
|
|
<span className="brand-name">Claude Agent</span>
|
2026-04-04 22:40:51 -04:00
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
2026-04-04 22:46:16 -04:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-04-04 22:40:51 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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">
|
2026-04-05 00:13:37 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
{/* ====== CHAT VIEW ====== */}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
{activeView === 'chat' && (
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-04-04 22:51:39 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
|
|
|
|
|
|
{/* 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.
|
2026-04-04 23:50:16 -04:00
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
</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.'}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
{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">
|
2026-04-05 00:26:21 -04:00
|
|
|
|
<textarea
|
2026-04-05 10:49:04 -04:00
|
|
|
|
className="chat-textarea"
|
2026-04-05 00:26:21 -04:00
|
|
|
|
value={chatInput}
|
|
|
|
|
|
onChange={e => setChatInput(e.target.value)}
|
2026-04-05 10:49:04 -04:00
|
|
|
|
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}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
/>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<button
|
|
|
|
|
|
className="chat-send-btn"
|
|
|
|
|
|
onClick={sendMessage}
|
|
|
|
|
|
disabled={chatWaiting || !chatInput.trim() || claudeStatus !== 'ready'}
|
|
|
|
|
|
>
|
|
|
|
|
|
{chatWaiting ? '…' : '➤'}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</button>
|
2026-04-04 22:46:16 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
{/* ====== 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>
|
2026-04-05 00:26:21 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
{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>
|
2026-04-04 22:40:51 -04:00
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
{taskForm.schedule_type === 'recurring' && (
|
|
|
|
|
|
<div className="form-field">
|
2026-04-04 22:40:51 -04:00
|
|
|
|
<label>Cron Expression</label>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<input value={taskForm.schedule_value}
|
|
|
|
|
|
onChange={e => setTaskForm(p => ({ ...p, schedule_value: e.target.value }))}
|
|
|
|
|
|
placeholder="0 9 * * 1-5 (weekdays at 9am)" />
|
2026-04-04 22:40:51 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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" />
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<div className="form-field">
|
2026-04-05 00:26:21 -04:00
|
|
|
|
<label>Permission Mode</label>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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>
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<div className="form-field">
|
2026-04-05 00:26:21 -04:00
|
|
|
|
<label>Timeout (seconds)</label>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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} />
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</div>
|
2026-04-04 22:40:51 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-actions">
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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>
|
2026-04-04 22:40:51 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
)}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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>
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
{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>
|
|
|
|
|
|
))}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
{/* ====== 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" />
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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" />
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
)}
|
|
|
|
|
|
{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>
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</div>
|
2026-04-05 10:49:04 -04:00
|
|
|
|
)}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
))}
|
2026-04-05 00:26:21 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-04-05 10:49:04 -04:00
|
|
|
|
|
|
|
|
|
|
</div>
|
2026-04-04 22:40:51 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
// ---- MessageContent: render text with basic code block support ----
|
|
|
|
|
|
const MessageContent = ({ content }) => {
|
|
|
|
|
|
if (!content) return null;
|
2026-04-04 22:40:51 -04:00
|
|
|
|
|
2026-04-05 10:49:04 -04:00
|
|
|
|
// Split on code fences
|
|
|
|
|
|
const parts = content.split(/(```[\s\S]*?```)/g);
|
2026-04-04 22:40:51 -04:00
|
|
|
|
return (
|
2026-04-05 10:49:04 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-04-04 22:40:51 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default App;
|