import React, { useEffect, useState, useRef } from 'react';
import { getNodeStreamUrl } from '../services/apiService';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
// Sub-component for each terminal window to encapsulate Xterm.js logic
const TerminalNodeItem = ({ nodeId, stats, onMount, onUnmount, nodeConfig, isAIProcessing }) => {
const terminalRef = useRef(null);
const xtermRef = useRef(null);
const fitAddonRef = useRef(null);
const sandbox = nodeConfig?.skill_config?.shell?.sandbox || {};
const mode = sandbox.mode || 'PASSIVE';
const isStrict = mode === 'STRICT';
// UI Feedback: boundary color & pulse logic
// AI Activity pulsing gives life to the interface when things are happening.
const borderClass = isStrict
? (isAIProcessing ? 'border-red-500/80 ring-1 ring-red-500/40 animate-[pulse-red_2s_infinite]' : 'border-red-900/50')
: (isAIProcessing ? 'border-blue-500/80 ring-1 ring-blue-500/40 animate-[pulse-blue_2s_infinite]' : 'border-blue-900/50');
const statusDotClass = isStrict ? 'bg-rose-500' : 'bg-blue-500';
useEffect(() => {
// Initialize Xterm
const xterm = new Terminal({
theme: {
background: '#030712', // Slightly deeper than 0d1117 for contrast
foreground: '#e6edf3',
cursor: isAIProcessing ? '#388bfd' : '#22c55e',
selectionBackground: '#388bfd',
},
fontSize: 12, // Slightly tighter for multi-view
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
cursorBlink: isAIProcessing,
cursorStyle: 'block',
convertEol: true,
scrollback: 1000,
disableStdin: true,
scrollOnOutput: true,
});
const fitAddon = new FitAddon();
xterm.loadAddon(fitAddon);
xterm.open(terminalRef.current);
setTimeout(() => fitAddon.fit(), 10);
xtermRef.current = xterm;
fitAddonRef.current = fitAddon;
onMount(nodeId, xterm);
const observer = new ResizeObserver(() => fitAddon.fit());
if (terminalRef.current) observer.observe(terminalRef.current);
return () => {
observer.disconnect();
onUnmount(nodeId);
xterm.dispose();
};
}, [nodeId]);
return (
<div className={`flex-1 flex flex-col min-w-0 border-r last:border-r-0 transition-all duration-700 ${borderClass}`}>
{/* compact Node Header */}
<div className={`bg-gray-900/80 px-3 py-1.5 border-b border-gray-800 flex justify-between items-center text-[9px] font-bold group relative overflow-hidden`}>
{/* Mode Indicator Overlay Gradient */}
<div className={`absolute inset-0 opacity-5 pointer-events-none bg-gradient-to-r ${isStrict ? 'from-rose-500' : 'from-blue-500'} to-transparent`}></div>
<div className="flex items-center gap-2 relative z-10">
<span className={`w-1.5 h-1.5 rounded-full ${statusDotClass} ${isAIProcessing ? 'animate-pulse' : ''}`}></span>
<span className="text-gray-200 tracking-wider truncate max-w-[80px]">{nodeId}</span>
<span className={`text-[8px] uppercase tracking-tighter px-1.5 py-0.5 rounded ${isStrict ? 'bg-rose-950/50 text-rose-400 border border-rose-900/50' : 'bg-blue-950/50 text-blue-400 border border-blue-900/50'}`}>
{mode}
</span>
</div>
<div className="flex space-x-3 text-gray-400 font-mono relative z-10">
<span title="CPU Usage">C: <span className="text-emerald-400">{stats?.cpu_usage_percent?.toFixed(1) || '0.0'}%</span></span>
<span title="Memory Usage">M: <span className="text-emerald-400">{stats?.memory_usage_percent?.toFixed(1) || '0.0'}%</span></span>
</div>
</div>
{/* Terminal Host */}
<div className="flex-1 overflow-hidden bg-[#030712] relative p-1">
<div ref={terminalRef} className="w-full h-full" />
{isAIProcessing && (
<div className="absolute top-1 right-2 pointer-events-none opacity-40">
<span className="flex h-2 w-2">
<span className={`${isStrict ? 'bg-rose-400' : 'bg-blue-400'} animate-ping absolute inline-flex h-full w-full rounded-full opacity-75`}></span>
<span className={`relative inline-flex rounded-full h-2 w-2 ${isStrict ? 'bg-rose-500' : 'bg-blue-500'}`}></span>
</span>
</div>
)}
</div>
<style dangerouslySetInnerHTML={{
__html: `
@keyframes pulse-red {
0%, 100% { border-color: rgba(244, 63, 94, 0.4); box-shadow: 0 0 5px rgba(244, 63, 94, 0.1); }
50% { border-color: rgba(244, 63, 94, 0.8); box-shadow: 0 0 15px rgba(244, 63, 94, 0.3); }
}
@keyframes pulse-blue {
0%, 100% { border-color: rgba(59, 130, 246, 0.4); box-shadow: 0 0 5px rgba(59, 130, 246, 0.1); }
50% { border-color: rgba(59, 130, 246, 0.8); box-shadow: 0 0 15px rgba(59, 130, 246, 0.3); }
}
`}} />
</div>
);
};
const MultiNodeConsole = ({ attachedNodeIds, nodes, isAIProcessing }) => {
const [nodeStats, setNodeStats] = useState({}); // node_id -> stats object
const [connected, setConnected] = useState(false);
const wsRef = useRef(null);
const xtermsRef = useRef({}); // nodeId -> Terminal instance
useEffect(() => {
if (!attachedNodeIds || attachedNodeIds.length === 0) return;
let reconnectTimer;
let isClosing = false; // Flag to prevent reconnect on intentional close
const connect = () => {
if (isClosing) return;
console.log("[📶] Connecting to Agent Mesh Stream (Multiplexed)...");
const ws = new WebSocket(getNodeStreamUrl());
wsRef.current = ws;
ws.onopen = () => {
console.log("[📶] Mesh Stream Connected");
setConnected(true);
};
ws.onmessage = (event) => {
if (isClosing) return;
try {
const msg = JSON.parse(event.data);
// Filter logic...
if (msg.node_id && !attachedNodeIds.includes(msg.node_id)) return;
// Update stats if present
if ((msg.event === 'mesh_heartbeat' || msg.event === 'heartbeat')) {
if (msg.data?.stats) {
setNodeStats(prev => ({ ...prev, [msg.node_id]: msg.data.stats }));
}
if (msg.data?.nodes) {
msg.data.nodes.forEach(n => {
setNodeStats(prev => ({ ...prev, [n.node_id]: n.stats }));
});
}
return;
}
const xterm = xtermsRef.current[msg.node_id];
if (xterm) {
switch (msg.event) {
case 'task_assigned':
if (msg.data.command) xterm.write(`\x1b[38;5;33m\x1b[1m$ ${msg.data.command}\x1b[0m\r\n`);
break;
case 'task_stdout': xterm.write(msg.data); break;
case 'skill_event': if (msg.data?.type === 'output') xterm.write(msg.data.data); break;
case 'browser_event': xterm.write(`\x1b[90m${msg.data.type === 'console' ? '🖥️' : '🌐'} ${msg.data.text || msg.data.url}\x1b[0m\r\n`); break;
}
}
} catch (e) { }
};
ws.onclose = () => {
if (!isClosing) {
console.warn("[📶] Mesh Stream Disconnected. Reconnecting in 3s...");
setConnected(false);
reconnectTimer = setTimeout(connect, 3000);
} else {
console.log("[📶] Mesh Stream Closed Intentionally.");
}
};
};
connect();
return () => {
isClosing = true;
if (wsRef.current) wsRef.current.close();
clearTimeout(reconnectTimer);
};
}, [JSON.stringify(attachedNodeIds)]); // Static dependency check
const handleMount = (nodeId, xterm) => {
xtermsRef.current[nodeId] = xterm;
};
const handleUnmount = (nodeId) => {
delete xtermsRef.current[nodeId];
};
if (!attachedNodeIds || attachedNodeIds.length === 0) return null;
return (
<div className={`flex flex-col h-full bg-gray-950 text-gray-300 font-mono border-t border-gray-800 transition-colors duration-500`}>
{/* Premium AI Observation Notice Banner */}
<div className={`bg-amber-500/10 border-b border-amber-500/20 px-4 py-1.5 flex justify-between items-center overflow-hidden shrink-0 z-10`} id="ai-terminal-notice">
<span className="text-[10px] font-black uppercase tracking-tighter text-amber-500 flex items-center gap-2">
<svg className={`w-3.5 h-3.5 shrink-0 ${isAIProcessing ? 'animate-bounce' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{isAIProcessing ? 'AI Agent actively controlling node terminals...' : 'Terminal Surfaced to AI :: Context Synchronized'}
</span>
<span className="text-[9px] text-amber-600 font-bold uppercase tracking-widest opacity-60 hidden md:inline font-mono">
OBSERVATION_MODE
</span>
</div>
{/* Swarm Status Bar */}
<div className="flex bg-gray-950 border-b border-gray-800 px-4 py-1.5 justify-between items-center shrink-0">
<span className="flex items-center text-[10px] font-black tracking-[0.2em] text-gray-500">
<span className={`w-1.5 h-1.5 rounded-full mr-2.5 ${connected ? 'bg-emerald-500' : 'bg-red-500'} ${isAIProcessing && connected ? 'animate-ping shadow-[0_0_8px_#10b981]' : ''}`}></span>
NODE_EXECUTION_SWARM
</span>
<div className="flex gap-4 items-center">
<span className="text-gray-600 uppercase tracking-widest text-[8px] font-bold">
Attached: {attachedNodeIds.length}
</span>
<span className="text-gray-500 uppercase tracking-widest text-[9px] font-bold py-0.5 px-2 rounded-full border border-gray-800 bg-gray-950/50">
Live Stream
</span>
</div>
</div>
{/* Multi-Terminal Display */}
<div className="flex flex-1 overflow-hidden divide-x divide-gray-800">
{attachedNodeIds.map(nodeId => (
<TerminalNodeItem
key={nodeId}
nodeId={nodeId}
stats={nodeStats[nodeId]}
onMount={handleMount}
onUnmount={handleUnmount}
nodeConfig={nodes?.find(n => n.node_id === nodeId)}
isAIProcessing={isAIProcessing}
/>
))}
</div>
</div>
);
};
export default MultiNodeConsole;