- 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>
546 lines
18 KiB
JavaScript
546 lines
18 KiB
JavaScript
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 = [
|
|
{ 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' },
|
|
{ name: 'history', description: 'Search input history (/history <term>)' },
|
|
];
|
|
|
|
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(() => {
|
|
const textarea = textareaRef.current;
|
|
if (textarea) {
|
|
textarea.style.height = 'auto';
|
|
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
|
}
|
|
}, [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]);
|
|
|
|
// 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() || attachedFiles.length > 0) {
|
|
addToHistory(message);
|
|
// Pass both message and files to onSend
|
|
onSend(message, attachedFiles);
|
|
setMessage('');
|
|
setAttachedFiles([]);
|
|
setShowCommands(false);
|
|
setHistoryIndex(-1);
|
|
setSavedInput('');
|
|
setUploadError(null);
|
|
}
|
|
};
|
|
|
|
const selectCommand = (cmdName) => {
|
|
setMessage(`/${cmdName} `);
|
|
setShowCommands(false);
|
|
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') {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSubmit(e);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<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 && (
|
|
<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>
|
|
)}
|
|
|
|
{/* 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);
|
|
setHistoryIndex(-1); // Reset history navigation on manual edit
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
|
|
disabled={disabled}
|
|
rows={1}
|
|
className={`
|
|
w-full bg-dark-800 border border-dark-700 rounded-xl
|
|
px-4 py-3 pr-12 text-dark-100 placeholder-dark-500
|
|
focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/20
|
|
resize-none transition-colors
|
|
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
|
`}
|
|
/>
|
|
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
|
|
{isProcessing ? 'ESC to stop' : attachedFiles.length > 0 ? `${attachedFiles.length} file(s)` : message.startsWith('/') ? 'Tab to complete' : '↑↓ history'}
|
|
</div>
|
|
</div>
|
|
|
|
{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>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-center mt-2 text-xs text-dark-600">
|
|
{isProcessing ? 'Generating... Press Enter to send follow-up, ESC to stop' : 'Paste, drag & drop, or click clip to attach files'}
|
|
</div>
|
|
</form>
|
|
);
|
|
});
|