This repository has been archived on 2026-04-05. You can view files and clone it, but cannot push or open issues or pull requests.
claude-persistent-agent/frontend/src/App.jsx

860 lines
32 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;