feat: Major UI improvements and SSH-only mode

- Tool rendering: Unified tool_use/tool_result cards with collapsible results
- Special rendering for WebSearch, WebFetch, Task, Write tools
- File upload support with drag & drop
- Permission dialog for tool approvals
- Status bar with session stats and permission mode toggle
- SSH-only mode: Removed local container execution
- Host switching disabled during active session with visual indicator
- Directory browser: Browse remote directories via SSH
- Recent directories dropdown with localStorage persistence
- Follow-up messages during generation
- Improved scroll behavior with "back to bottom" button

🤖 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 10:33:25 +01:00
parent 9eb0ecfb57
commit 960f2e137d
16 changed files with 3108 additions and 278 deletions

View File

@@ -4,6 +4,8 @@ import { MessageList } from './components/MessageList';
import { ChatInput } from './components/ChatInput';
import { Sidebar } from './components/Sidebar';
import { Header } from './components/Header';
import { StatusBar } from './components/StatusBar';
import { PermissionDialog } from './components/PermissionDialog';
// Slash command definitions
const SLASH_COMMANDS = {
@@ -81,6 +83,37 @@ const SLASH_COMMANDS = {
].join('\n');
addSystemMessage(info);
}
},
history: {
description: 'Search input history (/history <term>)',
execute: ({ args, addSystemMessage }) => {
const HISTORY_KEY = 'claude-webui-input-history';
try {
const stored = localStorage.getItem(HISTORY_KEY);
const history = stored ? JSON.parse(stored) : [];
const searchTerm = args.join(' ').toLowerCase();
if (!searchTerm) {
// Show recent history
const recent = history.slice(0, 10);
if (recent.length === 0) {
addSystemMessage('No input history found.');
} else {
addSystemMessage(`Recent inputs:\n${recent.map((h, i) => `${i + 1}. ${h.length > 80 ? h.slice(0, 80) + '...' : h}`).join('\n')}`);
}
} else {
// Search history
const matches = history.filter(h => h.toLowerCase().includes(searchTerm)).slice(0, 10);
if (matches.length === 0) {
addSystemMessage(`No history entries matching "${searchTerm}"`);
} else {
addSystemMessage(`History matching "${searchTerm}":\n${matches.map((h, i) => `${i + 1}. ${h.length > 80 ? h.slice(0, 80) + '...' : h}`).join('\n')}`);
}
}
} catch {
addSystemMessage('Failed to read input history.');
}
}
}
};
@@ -88,20 +121,29 @@ function App() {
const {
connected,
sessionActive,
sessionId,
messages,
currentProject,
currentHost,
isProcessing,
error,
sessionStats,
permissionMode,
controlInitialized,
pendingPermission,
startSession,
sendMessage,
stopSession,
stopGeneration,
clearMessages,
changePermissionMode,
respondToPermission,
setError,
setMessages
} = useClaudeSession();
const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui');
const [selectedHost, setSelectedHost] = useState('local');
const [selectedProject, setSelectedProject] = useState('/home/sumdex/projects');
const [selectedHost, setSelectedHost] = useState('neko');
const [sidebarOpen, setSidebarOpen] = useState(true);
const [resumeSession, setResumeSession] = useState(true);
@@ -114,9 +156,29 @@ function App() {
}]);
}, [setMessages]);
const handleStartSession = () => {
const handleStartSession = useCallback(() => {
startSession(selectedProject, resumeSession, selectedHost);
};
}, [startSession, selectedProject, resumeSession, selectedHost]);
const handleSelectProject = useCallback((path) => {
setSelectedProject(path);
}, []);
const handleSelectHost = useCallback((host) => {
setSelectedHost(host);
}, []);
const handleToggleSidebar = useCallback(() => {
setSidebarOpen(prev => !prev);
}, []);
const handleToggleResume = useCallback(() => {
setResumeSession(prev => !prev);
}, []);
const handleClearError = useCallback(() => {
setError(null);
}, [setError]);
// Handle slash commands
const handleCommand = useCallback((command, args) => {
@@ -139,11 +201,9 @@ function App() {
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 handleSendMessage = useCallback((message, attachedFiles = []) => {
// Check for slash command (only if no files attached)
if (message.startsWith('/') && attachedFiles.length === 0) {
const parts = message.slice(1).split(' ');
const command = parts[0];
const args = parts.slice(1);
@@ -156,26 +216,29 @@ function App() {
}
}
// Regular message
sendMessage(message);
};
// Regular message (with optional attachments)
if (message.trim() || attachedFiles.length > 0) {
sendMessage(message, attachedFiles);
}
}, [handleCommand, addSystemMessage, sendMessage]);
return (
<div className="flex h-screen bg-dark-950">
{/* Sidebar */}
<Sidebar
open={sidebarOpen}
onToggle={() => setSidebarOpen(!sidebarOpen)}
onToggle={handleToggleSidebar}
selectedProject={selectedProject}
onSelectProject={setSelectedProject}
onSelectProject={handleSelectProject}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
onSelectHost={handleSelectHost}
sessionActive={sessionActive}
activeHost={currentHost}
onStartSession={handleStartSession}
onStopSession={stopSession}
onClearMessages={clearMessages}
resumeSession={resumeSession}
onToggleResume={() => setResumeSession(!resumeSession)}
onToggleResume={handleToggleResume}
/>
{/* Main Content */}
@@ -185,7 +248,7 @@ function App() {
sessionActive={sessionActive}
currentProject={currentProject}
isProcessing={isProcessing}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onToggleSidebar={handleToggleSidebar}
/>
{/* Error Banner */}
@@ -193,7 +256,7 @@ function App() {
<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)}
onClick={handleClearError}
className="text-red-400 hover:text-red-300"
>
×
@@ -202,23 +265,44 @@ function App() {
)}
{/* Messages */}
<MessageList messages={messages} isProcessing={isProcessing} />
<MessageList
messages={messages}
isProcessing={isProcessing}
/>
{/* Status Bar */}
<StatusBar
sessionStats={sessionStats}
isProcessing={isProcessing}
connected={connected}
permissionMode={permissionMode}
controlInitialized={controlInitialized}
onChangeMode={changePermissionMode}
/>
{/* Input */}
<ChatInput
onSend={handleSendMessage}
disabled={!sessionActive || isProcessing}
onStop={stopGeneration}
disabled={!sessionActive}
isProcessing={isProcessing}
sessionId={sessionId}
placeholder={
!connected
? 'Connecting...'
: !sessionActive
? 'Start a session to begin'
: isProcessing
? 'Claude is thinking...'
: 'Type your message...'
}
/>
</div>
{/* Permission Dialog */}
<PermissionDialog
permission={pendingPermission}
onAllow={(requestId) => respondToPermission(requestId, true)}
onDeny={(requestId) => respondToPermission(requestId, false)}
/>
</div>
);
}