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

861 lines
32 KiB
React
Raw Normal View History

import React, { useState, useEffect, useRef, useCallback } from 'react';
2026-04-04 22:40:51 -04:00
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 = '';
2026-04-04 22:40:51 -04:00
const App = () => {
const [activeView, setActiveView] = useState('chat');
// Claude process status
const [claudeStatus, setClaudeStatus] = useState('unknown');
const [claudeSessionId, setClaudeSessionId] = useState(null);
2026-04-04 22:40:51 -04:00
// 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 */ }
};
2026-04-04 22:40:51 -04:00
useEffect(() => {
fetchTasks();
fetchMcpServers();
fetchChatSessions();
fetchClaudeStatus();
2026-04-04 22:40:51 -04:00
const interval = setInterval(() => {
fetchTasks();
fetchClaudeStatus();
}, 8000);
2026-04-04 22:40:51 -04:00
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (chatEndRef.current) {
chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [chatMessages]);
2026-04-04 22:40:51 -04:00
// ---- 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);
});
2026-04-04 22:40:51 -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
};
const loadHistorySession = async (sid) => {
setSelectedHistorySession(sid);
2026-04-04 22:51:39 -04:00
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 */ }
2026-04-04 22:51:39 -04:00
};
// ---- 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'); }
2026-04-04 22:51:39 -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
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
};
const editTask = (task) => {
setEditingTaskId(task.id);
setTaskForm({ ...task });
setShowTaskForm(true);
};
const fetchTaskRuns = async (taskId) => {
2026-04-04 22:40:51 -04:00
try {
const r = await fetch(`${API}/api/tasks/${taskId}/runs`);
if (r.ok) {
const runs = await r.json();
setTaskRuns(prev => ({ ...prev, [taskId]: runs }));
2026-04-04 22:40:51 -04:00
}
} catch (e) { /* ignore */ }
2026-04-04 22:40:51 -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
};
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
// ---- 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 (
<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>
{/* 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
<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>
)}
2026-04-04 22:51:39 -04:00
</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.
2026-04-04 23:50:16 -04:00
</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>
2026-04-04 22:46:16 -04:00
</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>
2026-04-04 22:40:51 -04:00
</select>
</div>
{taskForm.schedule_type === 'recurring' && (
<div className="form-field">
2026-04-04 22:40:51 -04:00
<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)" />
2026-04-04 22:40:51 -04:00
</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>
2026-04-04 22:40:51 -04:00
</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>
2026-04-04 22:40:51 -04:00
</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>
2026-04-04 22:40:51 -04:00
</div>
);
};
// ---- MessageContent: render text with basic code block support ----
const MessageContent = ({ content }) => {
if (!content) return null;
2026-04-04 22:40:51 -04:00
// Split on code fences
const parts = content.split(/(```[\s\S]*?```)/g);
2026-04-04 22:40:51 -04:00
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>
);
})}
2026-04-04 22:40:51 -04:00
</div>
);
};
export default App;