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:
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user