feat: Multi-session support with tabs, split view, and Mochi integration

- Add SessionContext for central state management
- Add TabBar component for session tabs
- Add SplitLayout for side-by-side session viewing
- Add ChatPanel wrapper component
- Refactor ChatInput to uncontrolled input for performance
- Add SCP file transfer for SSH hosts (Mochi)
- Fix stats undefined crash on session restore
- Store host info in sessions for upload routing

🤖 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-17 14:16:52 +01:00
parent 960f2e137d
commit cfee1711dc
9 changed files with 2122 additions and 467 deletions

View File

@@ -304,28 +304,60 @@ app.get('/api/browse', async (req, res) => {
});
// File upload endpoint
app.post('/api/upload/:sessionId', upload.array('files', 5), (req, res) => {
app.post('/api/upload/:sessionId', upload.array('files', 5), async (req, res) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const uploadedFiles = req.files.map(file => {
const sessionId = req.params.sessionId;
const session = sessions.get(sessionId);
const isSSH = session?.host?.connection?.type === 'ssh';
const uploadedFiles = [];
for (const file of req.files) {
// Convert container path to host path for Claude
// /projects/.claude-uploads/... -> /home/sumdex/projects/.claude-uploads/...
const hostPath = file.path.replace('/projects/', '/home/sumdex/projects/');
return {
let hostPath = file.path.replace('/projects/', '/home/sumdex/projects/');
// For SSH hosts, transfer file via SCP
if (isSSH && session.host) {
const { host: sshHost, user, port = 22 } = session.host.connection;
const remotePath = `/tmp/.claude-uploads/${file.filename}`;
try {
// Create remote directory if needed
const { execSync } = await import('child_process');
execSync(`ssh -o StrictHostKeyChecking=no -p ${port} ${user}@${sshHost} "mkdir -p /tmp/.claude-uploads"`, {
timeout: 10000
});
// Transfer file via SCP
execSync(`scp -o StrictHostKeyChecking=no -P ${port} "${file.path}" ${user}@${sshHost}:"${remotePath}"`, {
timeout: 60000 // 60s for large files
});
hostPath = remotePath;
console.log(`[Upload] SCP transferred ${file.filename} to ${session.hostId}:${remotePath}`);
} catch (scpErr) {
console.error(`[Upload] SCP error for ${file.filename}:`, scpErr.message);
// Fall back to local path (won't work but at least doesn't fail)
}
}
uploadedFiles.push({
originalName: file.originalname,
savedName: file.filename,
path: hostPath, // Use host path so Claude can read it
containerPath: file.path, // Keep container path for reference
path: hostPath,
containerPath: file.path,
size: file.size,
mimeType: file.mimetype,
isImage: file.mimetype.startsWith('image/')
};
});
});
}
console.log(`[Upload] Session ${req.params.sessionId}: ${uploadedFiles.length} files uploaded`);
console.log(`[Upload] Session ${sessionId}: ${uploadedFiles.length} files uploaded`);
res.json({ files: uploadedFiles });
} catch (err) {
console.error('[Upload] Error:', err);
@@ -582,8 +614,11 @@ wss.on('connection', (ws, req) => {
const { host: sshHost, user, port = 22 } = host.connection;
const sshTarget = `${user}@${sshHost}`;
// Use claudePath from config if specified, otherwise default to 'claude'
const claudeBin = host.claudePath || 'claude';
// Build the remote command with PATH setup for non-login shells
const remoteCmd = `export PATH="$HOME/.local/bin:$PATH" && cd ${projectPath} && claude ${claudeArgs.join(' ')}`;
const remoteCmd = `export PATH="$HOME/.local/bin:$PATH" && cd ${projectPath} && ${claudeBin} ${claudeArgs.join(' ')}`;
console.log(`[${sessionId}] SSH to ${sshTarget}:${port} - ${remoteCmd}`);
@@ -607,7 +642,7 @@ wss.on('connection', (ws, req) => {
});
}
sessions.set(sessionId, { process: claudeProcess, project: projectPath });
sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId });
sendToClient('session_started', {
sessionId,