feat: Add SSH remote execution for multi-host Claude sessions

- Backend: SSH execution via spawn() with -T flag for JSON streaming
- Backend: PATH setup for non-login shells on remote hosts
- Backend: History loading via SSH (tail -n 2000 for large files)
- Frontend: Host selector UI with colored buttons in Sidebar
- Frontend: Auto-select first project when host changes
- Frontend: Pass host parameter to history and session APIs
- Docker: Install openssh-client, mount SSH keys

Enables running Claude sessions on remote hosts via SSH while
viewing them through the web UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 22:59:34 +01:00
parent 60095d6e25
commit 9eb0ecfb57
6 changed files with 236 additions and 83 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings } from 'lucide-react';
import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings, Server } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
@@ -8,6 +8,8 @@ export function Sidebar({
onToggle,
selectedProject,
onSelectProject,
selectedHost,
onSelectHost,
sessionActive,
onStartSession,
onStopSession,
@@ -15,16 +17,39 @@ export function Sidebar({
resumeSession,
onToggleResume
}) {
const [hosts, setHosts] = useState([]);
const [projects, setProjects] = useState([]);
const [customPath, setCustomPath] = useState('');
// Fetch hosts on mount
useEffect(() => {
fetch(`${API_URL}/api/projects`)
fetch(`${API_URL}/api/hosts`)
.then(res => res.json())
.then(data => setProjects(data.projects || data))
.then(data => {
setHosts(data.hosts || []);
if (!selectedHost && data.defaultHost) {
onSelectHost(data.defaultHost);
}
})
.catch(console.error);
}, []);
// Fetch projects when host changes
useEffect(() => {
if (!selectedHost) return;
fetch(`${API_URL}/api/projects?host=${selectedHost}`)
.then(res => res.json())
.then(data => {
const projectList = data.projects || data;
setProjects(projectList);
// Auto-select first project when host changes
if (projectList.length > 0) {
onSelectProject(projectList[0].path);
}
})
.catch(console.error);
}, [selectedHost, onSelectProject]);
const handleCustomPath = () => {
if (customPath.trim()) {
onSelectProject(customPath.trim());
@@ -49,6 +74,36 @@ export function Sidebar({
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Host Selection */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
Host
</h3>
<div className="flex gap-2 flex-wrap">
{hosts.map((host) => (
<button
key={host.id}
onClick={() => onSelectHost(host.id)}
className={`
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
transition-colors border
${selectedHost === host.id
? 'border-orange-500/50 text-white'
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
}
`}
style={{
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
}}
>
<Server className="w-3.5 h-3.5" style={{ color: host.color }} />
<span>{host.name}</span>
{!host.isLocal && <span className="text-xs text-dark-500">(SSH)</span>}
</button>
))}
</div>
</div>
{/* Project Selection */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">