- 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>
227 lines
6.8 KiB
JavaScript
227 lines
6.8 KiB
JavaScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
||
import { useClaudeSession } from './hooks/useClaudeSession';
|
||
import { MessageList } from './components/MessageList';
|
||
import { ChatInput } from './components/ChatInput';
|
||
import { Sidebar } from './components/Sidebar';
|
||
import { Header } from './components/Header';
|
||
|
||
// Slash command definitions
|
||
const SLASH_COMMANDS = {
|
||
clear: {
|
||
description: 'Clear chat history (UI only)',
|
||
execute: ({ clearMessages, addSystemMessage }) => {
|
||
clearMessages();
|
||
addSystemMessage('Chat cleared');
|
||
}
|
||
},
|
||
help: {
|
||
description: 'Show available commands',
|
||
execute: ({ addSystemMessage }) => {
|
||
const helpText = Object.entries(SLASH_COMMANDS)
|
||
.map(([cmd, { description }]) => `/${cmd} - ${description}`)
|
||
.join('\n');
|
||
addSystemMessage(`Available commands:\n${helpText}`);
|
||
}
|
||
},
|
||
export: {
|
||
description: 'Export chat as Markdown',
|
||
execute: ({ messages, addSystemMessage }) => {
|
||
const markdown = messages.map(m => {
|
||
if (m.type === 'user') return `**You:** ${m.content}`;
|
||
if (m.type === 'assistant') return `**Claude:** ${m.content}`;
|
||
if (m.type === 'tool_use') return `> Tool: ${m.tool}`;
|
||
if (m.type === 'system') return `_${m.content}_`;
|
||
return '';
|
||
}).filter(Boolean).join('\n\n');
|
||
|
||
const blob = new Blob([markdown], { type: 'text/markdown' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `claude-chat-${new Date().toISOString().slice(0,10)}.md`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
addSystemMessage('Chat exported as Markdown');
|
||
}
|
||
},
|
||
scroll: {
|
||
description: 'Scroll to top or bottom (/scroll top|bottom)',
|
||
execute: ({ args, addSystemMessage }) => {
|
||
const direction = args[0] || 'bottom';
|
||
const container = document.querySelector('.overflow-y-auto');
|
||
if (container) {
|
||
if (direction === 'top') {
|
||
container.scrollTo({ top: 0, behavior: 'smooth' });
|
||
} else {
|
||
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||
}
|
||
}
|
||
addSystemMessage(`Scrolled to ${direction}`);
|
||
}
|
||
},
|
||
new: {
|
||
description: 'Start a new session (clears history)',
|
||
execute: ({ clearMessages, stopSession, startSession, selectedProject, addSystemMessage }) => {
|
||
stopSession();
|
||
clearMessages();
|
||
setTimeout(() => {
|
||
startSession(selectedProject, false); // false = don't resume
|
||
}, 500);
|
||
addSystemMessage('Starting new session...');
|
||
}
|
||
},
|
||
info: {
|
||
description: 'Show session info',
|
||
execute: ({ connected, sessionActive, currentProject, messages, addSystemMessage }) => {
|
||
const info = [
|
||
`Connected: ${connected ? 'Yes' : 'No'}`,
|
||
`Session: ${sessionActive ? 'Active' : 'Inactive'}`,
|
||
`Project: ${currentProject || 'None'}`,
|
||
`Messages: ${messages.length}`
|
||
].join('\n');
|
||
addSystemMessage(info);
|
||
}
|
||
}
|
||
};
|
||
|
||
function App() {
|
||
const {
|
||
connected,
|
||
sessionActive,
|
||
messages,
|
||
currentProject,
|
||
isProcessing,
|
||
error,
|
||
startSession,
|
||
sendMessage,
|
||
stopSession,
|
||
clearMessages,
|
||
setError,
|
||
setMessages
|
||
} = useClaudeSession();
|
||
|
||
const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui');
|
||
const [selectedHost, setSelectedHost] = useState('local');
|
||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||
const [resumeSession, setResumeSession] = useState(true);
|
||
|
||
// Add system message helper
|
||
const addSystemMessage = useCallback((content) => {
|
||
setMessages(prev => [...prev, {
|
||
type: 'system',
|
||
content,
|
||
timestamp: Date.now()
|
||
}]);
|
||
}, [setMessages]);
|
||
|
||
const handleStartSession = () => {
|
||
startSession(selectedProject, resumeSession, selectedHost);
|
||
};
|
||
|
||
// Handle slash commands
|
||
const handleCommand = useCallback((command, args) => {
|
||
const cmd = SLASH_COMMANDS[command.toLowerCase()];
|
||
if (cmd) {
|
||
cmd.execute({
|
||
clearMessages,
|
||
addSystemMessage,
|
||
messages,
|
||
stopSession,
|
||
startSession,
|
||
selectedProject,
|
||
connected,
|
||
sessionActive,
|
||
currentProject,
|
||
args
|
||
});
|
||
return true;
|
||
}
|
||
return false;
|
||
}, [clearMessages, addSystemMessage, messages, stopSession, startSession, selectedProject, connected, sessionActive, currentProject]);
|
||
|
||
const handleSendMessage = (message) => {
|
||
if (!message.trim()) return;
|
||
|
||
// Check for slash command
|
||
if (message.startsWith('/')) {
|
||
const parts = message.slice(1).split(' ');
|
||
const command = parts[0];
|
||
const args = parts.slice(1);
|
||
|
||
if (handleCommand(command, args)) {
|
||
return; // Command handled
|
||
} else {
|
||
addSystemMessage(`Unknown command: /${command}. Type /help for available commands.`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Regular message
|
||
sendMessage(message);
|
||
};
|
||
|
||
return (
|
||
<div className="flex h-screen bg-dark-950">
|
||
{/* Sidebar */}
|
||
<Sidebar
|
||
open={sidebarOpen}
|
||
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||
selectedProject={selectedProject}
|
||
onSelectProject={setSelectedProject}
|
||
selectedHost={selectedHost}
|
||
onSelectHost={setSelectedHost}
|
||
sessionActive={sessionActive}
|
||
onStartSession={handleStartSession}
|
||
onStopSession={stopSession}
|
||
onClearMessages={clearMessages}
|
||
resumeSession={resumeSession}
|
||
onToggleResume={() => setResumeSession(!resumeSession)}
|
||
/>
|
||
|
||
{/* Main Content */}
|
||
<div className="flex-1 flex flex-col min-w-0">
|
||
<Header
|
||
connected={connected}
|
||
sessionActive={sessionActive}
|
||
currentProject={currentProject}
|
||
isProcessing={isProcessing}
|
||
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||
/>
|
||
|
||
{/* Error Banner */}
|
||
{error && (
|
||
<div className="bg-red-900/50 border-b border-red-800 px-4 py-2 flex justify-between items-center">
|
||
<span className="text-red-200 text-sm">{error}</span>
|
||
<button
|
||
onClick={() => setError(null)}
|
||
className="text-red-400 hover:text-red-300"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Messages */}
|
||
<MessageList messages={messages} isProcessing={isProcessing} />
|
||
|
||
{/* Input */}
|
||
<ChatInput
|
||
onSend={handleSendMessage}
|
||
disabled={!sessionActive || isProcessing}
|
||
placeholder={
|
||
!connected
|
||
? 'Connecting...'
|
||
: !sessionActive
|
||
? 'Start a session to begin'
|
||
: isProcessing
|
||
? 'Claude is thinking...'
|
||
: 'Type your message...'
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|