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

@@ -1,5 +1,39 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Loader2, Command } from 'lucide-react';
import { useState, useRef, useEffect, memo, useCallback } from 'react';
import { Send, Square, Command, History, Paperclip, X, Image, FileText } from 'lucide-react';
// LocalStorage key for input history
const HISTORY_KEY = 'claude-webui-input-history';
const MAX_HISTORY = 100;
const MAX_FILES = 5;
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// Allowed file types
const ALLOWED_EXTENSIONS = [
// Images
'.png', '.jpg', '.jpeg', '.gif', '.webp',
// Text/Code
'.txt', '.md', '.csv', '.html', '.css', '.js', '.ts', '.jsx', '.tsx',
'.json', '.xml', '.yaml', '.yml', '.py', '.go', '.rs', '.java', '.c',
'.cpp', '.h', '.sh', '.bash', '.zsh', '.toml', '.ini', '.conf', '.sql',
'.rb', '.php', '.swift', '.kt', '.scala', '.r', '.lua', '.pl', '.pm'
];
// Load history from localStorage
function loadHistory() {
try {
const stored = localStorage.getItem(HISTORY_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
// Save history to localStorage
function saveHistory(history) {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY)));
} catch {}
}
// Available slash commands for autocomplete
const COMMANDS = [
@@ -9,14 +43,24 @@ const COMMANDS = [
{ name: 'scroll', description: 'Scroll to top or bottom' },
{ name: 'new', description: 'Start a new session' },
{ name: 'info', description: 'Show session info' },
{ name: 'history', description: 'Search input history (/history <term>)' },
];
export function ChatInput({ onSend, disabled, placeholder }) {
export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isProcessing, placeholder, sessionId }) {
const [message, setMessage] = useState('');
const [showCommands, setShowCommands] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [filteredCommands, setFilteredCommands] = useState(COMMANDS);
const [inputHistory, setInputHistory] = useState(() => loadHistory());
const [historyIndex, setHistoryIndex] = useState(-1);
const [savedInput, setSavedInput] = useState('');
const [showHistorySearch, setShowHistorySearch] = useState(false);
const [historySearchResults, setHistorySearchResults] = useState([]);
const [attachedFiles, setAttachedFiles] = useState([]);
const [isDragOver, setIsDragOver] = useState(false);
const [uploadError, setUploadError] = useState(null);
const textareaRef = useRef(null);
const fileInputRef = useRef(null);
// Auto-resize textarea
useEffect(() => {
@@ -42,12 +86,153 @@ export function ChatInput({ onSend, disabled, placeholder }) {
}
}, [message]);
// Add message to history
const addToHistory = useCallback((msg) => {
const trimmed = msg.trim();
if (!trimmed) return;
setInputHistory(prev => {
// Remove duplicate if exists
const filtered = prev.filter(h => h !== trimmed);
const newHistory = [trimmed, ...filtered].slice(0, MAX_HISTORY);
saveHistory(newHistory);
return newHistory;
});
}, []);
// Validate file
const validateFile = useCallback((file) => {
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext) && !file.type.startsWith('image/')) {
return `File type not allowed: ${ext}`;
}
if (file.size > MAX_FILE_SIZE) {
return `File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB (max 10MB)`;
}
return null;
}, []);
// Process and add files
const processFiles = useCallback((files) => {
setUploadError(null);
const fileArray = Array.from(files);
const remaining = MAX_FILES - attachedFiles.length;
if (fileArray.length > remaining) {
setUploadError(`Only ${remaining} more file(s) can be added (max ${MAX_FILES})`);
return;
}
const newFiles = [];
for (const file of fileArray) {
const error = validateFile(file);
if (error) {
setUploadError(error);
return;
}
// Create preview for images
const isImage = file.type.startsWith('image/');
const fileData = {
file,
name: file.name,
size: file.size,
type: file.type,
isImage,
preview: isImage ? URL.createObjectURL(file) : null,
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
};
newFiles.push(fileData);
}
setAttachedFiles(prev => [...prev, ...newFiles]);
}, [attachedFiles.length, validateFile]);
// Remove attached file
const removeFile = useCallback((id) => {
setAttachedFiles(prev => {
const file = prev.find(f => f.id === id);
if (file?.preview) {
URL.revokeObjectURL(file.preview);
}
return prev.filter(f => f.id !== id);
});
}, []);
// Handle paste event
const handlePaste = useCallback((e) => {
const items = e.clipboardData?.items;
if (!items) return;
const files = [];
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) files.push(file);
}
}
if (files.length > 0) {
e.preventDefault();
processFiles(files);
}
}, [processFiles]);
// Handle drag and drop
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
processFiles(files);
}
}, [processFiles]);
// Handle file input change
const handleFileSelect = useCallback((e) => {
const files = e.target.files;
if (files && files.length > 0) {
processFiles(files);
}
// Reset input so same file can be selected again
e.target.value = '';
}, [processFiles]);
// Cleanup previews on unmount
useEffect(() => {
return () => {
attachedFiles.forEach(f => {
if (f.preview) URL.revokeObjectURL(f.preview);
});
};
}, []);
const handleSubmit = (e) => {
e.preventDefault();
if (message.trim() && !disabled) {
onSend(message);
if (message.trim() || attachedFiles.length > 0) {
addToHistory(message);
// Pass both message and files to onSend
onSend(message, attachedFiles);
setMessage('');
setAttachedFiles([]);
setShowCommands(false);
setHistoryIndex(-1);
setSavedInput('');
setUploadError(null);
}
};
@@ -57,7 +242,51 @@ export function ChatInput({ onSend, disabled, placeholder }) {
textareaRef.current?.focus();
};
// Select from history search
const selectHistoryItem = (item) => {
setMessage(item);
setShowHistorySearch(false);
setHistorySearchResults([]);
textareaRef.current?.focus();
};
const handleKeyDown = (e) => {
// ESC to stop generation
if (e.key === 'Escape') {
if (isProcessing && onStop) {
e.preventDefault();
onStop();
return;
}
if (showCommands) {
setShowCommands(false);
return;
}
if (showHistorySearch) {
setShowHistorySearch(false);
return;
}
}
// Handle history search results navigation
if (showHistorySearch && historySearchResults.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, historySearchResults.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
return;
}
if (e.key === 'Enter') {
e.preventDefault();
selectHistoryItem(historySearchResults[selectedIndex]);
return;
}
}
// Handle command selection
if (showCommands) {
if (e.key === 'ArrowDown') {
@@ -75,8 +304,33 @@ export function ChatInput({ onSend, disabled, placeholder }) {
selectCommand(filteredCommands[selectedIndex].name);
return;
}
if (e.key === 'Escape') {
setShowCommands(false);
}
// Arrow up/down for history navigation (only when not in command/search mode)
if (!showCommands && !showHistorySearch && inputHistory.length > 0) {
if (e.key === 'ArrowUp') {
// Only navigate history if at start of input or input is empty
const textarea = textareaRef.current;
if (textarea && (textarea.selectionStart === 0 || message === '')) {
e.preventDefault();
if (historyIndex === -1) {
setSavedInput(message);
}
const newIndex = Math.min(historyIndex + 1, inputHistory.length - 1);
setHistoryIndex(newIndex);
setMessage(inputHistory[newIndex]);
return;
}
}
if (e.key === 'ArrowDown' && historyIndex >= 0) {
e.preventDefault();
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
if (newIndex === -1) {
setMessage(savedInput);
} else {
setMessage(inputHistory[newIndex]);
}
return;
}
}
@@ -88,8 +342,109 @@ export function ChatInput({ onSend, disabled, placeholder }) {
};
return (
<form onSubmit={handleSubmit} className="p-4 border-t border-dark-800 bg-dark-900">
<form
onSubmit={handleSubmit}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`p-4 border-t border-dark-800 bg-dark-900 transition-colors ${isDragOver ? 'bg-orange-900/20 border-orange-500/50' : ''}`}
>
{/* Drag overlay */}
{isDragOver && (
<div className="absolute inset-0 bg-orange-900/30 border-2 border-dashed border-orange-500 rounded-lg flex items-center justify-center z-20 pointer-events-none">
<div className="text-orange-400 text-lg font-medium">Drop files here</div>
</div>
)}
{/* Upload error */}
{uploadError && (
<div className="max-w-4xl mx-auto mb-2 px-3 py-2 bg-red-900/30 border border-red-700 rounded-lg text-red-400 text-sm flex justify-between items-center">
<span>{uploadError}</span>
<button type="button" onClick={() => setUploadError(null)} className="text-red-500 hover:text-red-400">
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Attached files preview */}
{attachedFiles.length > 0 && (
<div className="max-w-4xl mx-auto mb-3 flex gap-2 flex-wrap">
{attachedFiles.map((file) => (
<div
key={file.id}
className="relative group bg-dark-800 border border-dark-700 rounded-lg overflow-hidden"
>
{file.isImage ? (
<div className="w-16 h-16 relative">
<img
src={file.preview}
alt={file.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
type="button"
onClick={() => removeFile(file.id)}
className="p-1 bg-red-600 rounded-full text-white hover:bg-red-500"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
) : (
<div className="px-3 py-2 flex items-center gap-2 max-w-48">
<FileText className="w-4 h-4 text-dark-400 flex-shrink-0" />
<span className="text-dark-300 text-xs truncate">{file.name}</span>
<button
type="button"
onClick={() => removeFile(file.id)}
className="p-0.5 text-dark-500 hover:text-red-400 flex-shrink-0"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
))}
{attachedFiles.length < MAX_FILES && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-16 h-16 border border-dashed border-dark-600 rounded-lg flex items-center justify-center text-dark-500 hover:text-dark-400 hover:border-dark-500 transition-colors"
title="Add more files"
>
<Paperclip className="w-5 h-5" />
</button>
)}
</div>
)}
<div className="flex gap-3 items-end max-w-4xl mx-auto">
{/* File input (hidden) */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ALLOWED_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
{/* Attach button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || attachedFiles.length >= MAX_FILES}
className={`p-3 rounded-xl transition-all ${
disabled || attachedFiles.length >= MAX_FILES
? 'bg-dark-800 text-dark-600 cursor-not-allowed'
: 'bg-dark-800 text-dark-400 hover:text-orange-400 hover:bg-dark-700'
}`}
title={attachedFiles.length >= MAX_FILES ? `Max ${MAX_FILES} files` : 'Attach files (or paste/drag)'}
>
<Paperclip className="w-5 h-5" />
</button>
<div className="flex-1 relative">
{/* Command autocomplete dropdown */}
{showCommands && (
@@ -110,12 +465,37 @@ export function ChatInput({ onSend, disabled, placeholder }) {
</div>
)}
{/* History search results dropdown */}
{showHistorySearch && historySearchResults.length > 0 && (
<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 max-h-64 overflow-y-auto">
<div className="px-3 py-2 text-xs text-dark-500 border-b border-dark-700 flex items-center gap-2">
<History className="w-3 h-3" />
History search results ({historySearchResults.length})
</div>
{historySearchResults.map((item, index) => (
<button
key={index}
type="button"
onClick={() => selectHistoryItem(item)}
className={`w-full px-4 py-2.5 text-left transition-colors text-sm truncate
${index === selectedIndex ? 'bg-orange-600/20 text-orange-400' : 'hover:bg-dark-700 text-dark-200'}`}
>
{item.length > 100 ? item.slice(0, 100) + '...' : item}
</button>
))}
</div>
)}
<textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onChange={(e) => {
setMessage(e.target.value);
setHistoryIndex(-1); // Reset history navigation on manual edit
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
onPaste={handlePaste}
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
disabled={disabled}
rows={1}
className={`
@@ -127,32 +507,39 @@ export function ChatInput({ onSend, disabled, placeholder }) {
`}
/>
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
{message.startsWith('/') ? 'Tab to complete' : 'Shift+Enter for newline'}
{isProcessing ? 'ESC to stop' : attachedFiles.length > 0 ? `${attachedFiles.length} file(s)` : message.startsWith('/') ? 'Tab to complete' : '↑↓ history'}
</div>
</div>
<button
type="submit"
disabled={disabled || !message.trim()}
className={`
p-3 rounded-xl transition-all
${disabled || !message.trim()
? 'bg-dark-800 text-dark-600 cursor-not-allowed'
: 'bg-orange-600 hover:bg-orange-500 text-white shadow-lg shadow-orange-600/20'
}
`}
>
{disabled ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
{isProcessing ? (
<button
type="button"
onClick={onStop}
className="p-3 rounded-xl transition-all bg-red-600 hover:bg-red-500 text-white shadow-lg shadow-red-600/20"
title="Stop generation (ESC)"
>
<Square className="w-5 h-5" />
</button>
) : (
<button
type="submit"
disabled={disabled || (!message.trim() && attachedFiles.length === 0)}
className={`
p-3 rounded-xl transition-all
${disabled || (!message.trim() && attachedFiles.length === 0)
? 'bg-dark-800 text-dark-600 cursor-not-allowed'
: 'bg-orange-600 hover:bg-orange-500 text-white shadow-lg shadow-orange-600/20'
}
`}
>
<Send className="w-5 h-5" />
)}
</button>
</button>
)}
</div>
<div className="text-center mt-2 text-xs text-dark-600">
Messages are processed via Claude Code JSON streaming
{isProcessing ? 'Generating... Press Enter to send follow-up, ESC to stop' : 'Paste, drag & drop, or click clip to attach files'}
</div>
</form>
);
}
});