feat: Add multi-host config and dynamic project scanning

Backend:
- Load hosts from config/hosts.json
- New /api/hosts endpoint listing available hosts
- Dynamic project scanning with configurable depth
- Support for local and SSH hosts (SSH execution coming next)

Frontend (by Web-UI Claude):
- Slash commands: /clear, /help, /export, /scroll, /new, /info
- Chat export as Markdown

Config:
- hosts.json defines hosts with connection info and base paths
- hosts.example.json as template (real config is gitignored)
- Each host has name, description, color, icon, basePaths

Next steps:
- SSH command execution for remote hosts
- Frontend host selector UI
- Multi-agent collaboration

🤖 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:11:22 +01:00
parent 52792268fa
commit 38ab89932a
7 changed files with 354 additions and 21 deletions

View File

@@ -1,10 +1,89 @@
import { useState, useRef, useEffect } from 'react';
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,
@@ -17,21 +96,67 @@ function App() {
sendMessage,
stopSession,
clearMessages,
setError
setError,
setMessages
} = useClaudeSession();
const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui');
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);
};
const handleSendMessage = (message) => {
if (message.trim()) {
sendMessage(message);
// 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 (

View File

@@ -1,8 +1,21 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Loader2 } from 'lucide-react';
import { Send, Loader2, Command } from 'lucide-react';
// Available slash commands for autocomplete
const COMMANDS = [
{ name: 'help', description: 'Show available commands' },
{ name: 'clear', description: 'Clear chat history' },
{ name: 'export', description: 'Export chat as Markdown' },
{ name: 'scroll', description: 'Scroll to top or bottom' },
{ name: 'new', description: 'Start a new session' },
{ name: 'info', description: 'Show session info' },
];
export function ChatInput({ onSend, disabled, placeholder }) {
const [message, setMessage] = useState('');
const [showCommands, setShowCommands] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [filteredCommands, setFilteredCommands] = useState(COMMANDS);
const textareaRef = useRef(null);
// Auto-resize textarea
@@ -14,15 +27,60 @@ export function ChatInput({ onSend, disabled, placeholder }) {
}
}, [message]);
// Handle command filtering
useEffect(() => {
if (message.startsWith('/')) {
const query = message.slice(1).toLowerCase();
const filtered = COMMANDS.filter(cmd =>
cmd.name.toLowerCase().startsWith(query)
);
setFilteredCommands(filtered);
setShowCommands(filtered.length > 0 && message.length > 0);
setSelectedIndex(0);
} else {
setShowCommands(false);
}
}, [message]);
const handleSubmit = (e) => {
e.preventDefault();
if (message.trim() && !disabled) {
onSend(message);
setMessage('');
setShowCommands(false);
}
};
const selectCommand = (cmdName) => {
setMessage(`/${cmdName} `);
setShowCommands(false);
textareaRef.current?.focus();
};
const handleKeyDown = (e) => {
// Handle command selection
if (showCommands) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, filteredCommands.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && filteredCommands.length > 0)) {
e.preventDefault();
selectCommand(filteredCommands[selectedIndex].name);
return;
}
if (e.key === 'Escape') {
setShowCommands(false);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
@@ -33,6 +91,25 @@ export function ChatInput({ onSend, disabled, placeholder }) {
<form onSubmit={handleSubmit} className="p-4 border-t border-dark-800 bg-dark-900">
<div className="flex gap-3 items-end max-w-4xl mx-auto">
<div className="flex-1 relative">
{/* Command autocomplete dropdown */}
{showCommands && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-dark-800 border border-dark-700 rounded-lg shadow-xl overflow-hidden z-10">
{filteredCommands.map((cmd, index) => (
<button
key={cmd.name}
type="button"
onClick={() => selectCommand(cmd.name)}
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors
${index === selectedIndex ? 'bg-orange-600/20 text-orange-400' : 'hover:bg-dark-700 text-dark-200'}`}
>
<Command className="w-4 h-4 text-dark-500" />
<span className="font-medium">/{cmd.name}</span>
<span className="text-dark-500 text-sm">{cmd.description}</span>
</button>
))}
</div>
)}
<textarea
ref={textareaRef}
value={message}
@@ -50,7 +127,7 @@ export function ChatInput({ onSend, disabled, placeholder }) {
`}
/>
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
Shift+Enter for newline
{message.startsWith('/') ? 'Tab to complete' : 'Shift+Enter for newline'}
</div>
</div>

View File

@@ -295,6 +295,7 @@ export function useClaudeSession() {
sendMessage,
stopSession,
clearMessages,
setError
setError,
setMessages
};
}