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>
);
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,202 @@
import { useState } from 'react';
import { ShieldAlert, Check, X, ChevronDown, ChevronUp, ClipboardList, ThumbsUp, ThumbsDown, Play } from 'lucide-react';
// Tool risk levels and descriptions
const TOOL_INFO = {
Bash: { risk: 'high', description: 'Execute shell commands' },
Edit: { risk: 'medium', description: 'Modify file contents' },
Write: { risk: 'medium', description: 'Create or overwrite files' },
Read: { risk: 'low', description: 'Read file contents' },
Glob: { risk: 'low', description: 'Search for files by pattern' },
Grep: { risk: 'low', description: 'Search file contents' },
Task: { risk: 'low', description: 'Launch sub-agent' },
WebFetch: { risk: 'low', description: 'Fetch web content' },
WebSearch: { risk: 'low', description: 'Search the web' },
TodoWrite: { risk: 'low', description: 'Update task list' },
NotebookEdit: { risk: 'medium', description: 'Edit Jupyter notebook' },
ExitPlanMode: { risk: 'low', description: 'Exit plan mode and start implementation' },
default: { risk: 'medium', description: 'Execute tool' }
};
function getRiskColor(risk) {
switch (risk) {
case 'high': return 'text-red-400 bg-red-500/20 border-red-500/30';
case 'medium': return 'text-yellow-400 bg-yellow-500/20 border-yellow-500/30';
case 'low': return 'text-green-400 bg-green-500/20 border-green-500/30';
default: return 'text-dark-400 bg-dark-700 border-dark-600';
}
}
export function PermissionDialog({ permission, onAllow, onDeny }) {
const [showDetails, setShowDetails] = useState(false);
if (!permission) return null;
const { requestId, toolName, toolInput, blockedPath, isPlanApproval } = permission;
const toolInfo = TOOL_INFO[toolName] || TOOL_INFO.default;
const riskColor = getRiskColor(toolInfo.risk);
// Format tool input for display
const formatInput = (input) => {
if (!input) return null;
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
};
const inputStr = formatInput(toolInput);
// Special UI for Plan Approval
if (isPlanApproval) {
const launchSwarm = toolInput?.launchSwarm;
const teammateCount = toolInput?.teammateCount;
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-gradient-to-br from-purple-900/50 to-dark-900 border-2 border-purple-500/50 rounded-xl shadow-2xl shadow-purple-500/20 max-w-lg w-full">
{/* Header */}
<div className="flex items-center gap-3 p-5 border-b border-purple-500/30 bg-purple-900/20">
<div className="w-12 h-12 rounded-lg bg-purple-500/30 border border-purple-400/50 flex items-center justify-center">
<ClipboardList className="w-6 h-6 text-purple-300" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-purple-200">Plan Review</h3>
<p className="text-sm text-purple-400/70">Claude has created an implementation plan for your approval</p>
</div>
</div>
{/* Swarm info if applicable */}
{launchSwarm && (
<div className="px-5 py-3 bg-indigo-900/20 border-b border-indigo-500/30 flex items-center gap-2">
<Play className="w-4 h-4 text-indigo-400" />
<span className="text-sm text-indigo-300">
This plan will launch a swarm{teammateCount ? ` with ${teammateCount} teammates` : ''}
</span>
</div>
)}
{/* Content */}
<div className="p-5">
<p className="text-sm text-dark-300 mb-4">
Review the plan in the conversation above. Once approved, Claude will begin implementing the changes.
</p>
{/* Show details toggle */}
{inputStr && (
<div>
<button
onClick={() => setShowDetails(!showDetails)}
className="flex items-center gap-1 text-xs text-purple-400 hover:text-purple-300"
>
{showDetails ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
{showDetails ? 'Hide' : 'Show'} tool parameters
</button>
{showDetails && (
<pre className="mt-2 text-xs bg-dark-800/50 rounded p-2 overflow-auto max-h-32 text-dark-400">
{inputStr}
</pre>
)}
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-3 p-5 border-t border-purple-500/30">
<button
onClick={() => onDeny(requestId)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-dark-800 hover:bg-dark-700 text-dark-200 rounded-lg font-medium transition-colors border border-dark-600"
>
<ThumbsDown className="w-4 h-4" />
Reject Plan
</button>
<button
onClick={() => onAllow(requestId)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-green-600 hover:bg-green-500 text-white rounded-lg font-medium transition-colors"
>
<ThumbsUp className="w-4 h-4" />
Approve Plan
</button>
</div>
</div>
</div>
);
}
// Standard permission dialog
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-dark-900 border border-dark-700 rounded-lg shadow-xl max-w-lg w-full">
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-dark-700">
<div className={`p-2 rounded-lg ${riskColor}`}>
<ShieldAlert className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-medium text-dark-100">Permission Required</h3>
<p className="text-xs text-dark-400">Claude wants to use a tool</p>
</div>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Tool name and description */}
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-mono text-dark-200">{toolName}</span>
<p className="text-xs text-dark-500">{toolInfo.description}</p>
</div>
<span className={`text-xs px-2 py-0.5 rounded border ${riskColor}`}>
{toolInfo.risk} risk
</span>
</div>
{/* Blocked path if any */}
{blockedPath && (
<div className="text-xs text-dark-400 bg-dark-800 rounded p-2">
<span className="text-dark-500">Path: </span>
<code className="text-orange-400">{blockedPath}</code>
</div>
)}
{/* Tool input (collapsible) */}
{inputStr && (
<div>
<button
onClick={() => setShowDetails(!showDetails)}
className="flex items-center gap-1 text-xs text-dark-400 hover:text-dark-300"
>
{showDetails ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
{showDetails ? 'Hide' : 'Show'} details
</button>
{showDetails && (
<pre className="mt-2 text-xs bg-dark-800 rounded p-2 overflow-auto max-h-48 text-dark-300">
{inputStr}
</pre>
)}
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 p-4 border-t border-dark-700">
<button
onClick={() => onDeny(requestId)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-dark-800 hover:bg-dark-700 text-dark-300 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
Deny
</button>
<button
onClick={() => onAllow(requestId)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg transition-colors"
>
<Check className="w-4 h-4" />
Allow
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,42 @@
import { useState, useEffect } from 'react';
import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings, Server } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react';
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2 } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
const MAX_RECENT_DIRS = 10;
// Load recent directories from localStorage
function loadRecentDirs() {
try {
const stored = localStorage.getItem(RECENT_DIRS_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
// Save recent directories to localStorage
function saveRecentDirs(dirs) {
try {
localStorage.setItem(RECENT_DIRS_KEY, JSON.stringify(dirs));
} catch (e) {
console.error('Failed to save recent dirs:', e);
}
}
// Add a directory to recent list for a host
function addRecentDir(hostId, path) {
const recent = loadRecentDirs();
const hostRecent = recent[hostId] || [];
// Remove if already exists, then add to front
const filtered = hostRecent.filter(p => p !== path);
const updated = [path, ...filtered].slice(0, MAX_RECENT_DIRS);
recent[hostId] = updated;
saveRecentDirs(recent);
return updated;
}
export function Sidebar({
open,
@@ -11,6 +46,7 @@ export function Sidebar({
selectedHost,
onSelectHost,
sessionActive,
activeHost,
onStartSession,
onStopSession,
onClearMessages,
@@ -18,8 +54,13 @@ export function Sidebar({
onToggleResume
}) {
const [hosts, setHosts] = useState([]);
const [projects, setProjects] = useState([]);
const [customPath, setCustomPath] = useState('');
const [recentDirs, setRecentDirs] = useState([]);
const [showBrowser, setShowBrowser] = useState(false);
const [browserPath, setBrowserPath] = useState('~');
const [browserDirs, setBrowserDirs] = useState([]);
const [browserLoading, setBrowserLoading] = useState(false);
const [browserError, setBrowserError] = useState(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
// Fetch hosts on mount
useEffect(() => {
@@ -34,27 +75,57 @@ export function Sidebar({
.catch(console.error);
}, []);
// Fetch projects when host changes
// Load recent directories when host changes
useEffect(() => {
if (!selectedHost) return;
fetch(`${API_URL}/api/projects?host=${selectedHost}`)
.then(res => res.json())
.then(data => {
const projectList = data.projects || data;
setProjects(projectList);
// Auto-select first project when host changes
if (projectList.length > 0) {
onSelectProject(projectList[0].path);
}
})
.catch(console.error);
const recent = loadRecentDirs();
setRecentDirs(recent[selectedHost] || []);
}, [selectedHost]);
// Handle selecting a directory (from dropdown or browser)
const handleSelectDir = useCallback((path) => {
onSelectProject(path);
const updated = addRecentDir(selectedHost, path);
setRecentDirs(updated);
setDropdownOpen(false);
setShowBrowser(false);
}, [selectedHost, onSelectProject]);
const handleCustomPath = () => {
if (customPath.trim()) {
onSelectProject(customPath.trim());
setCustomPath('');
// Browse directories on host
const browsePath = useCallback(async (path) => {
if (!selectedHost) return;
setBrowserLoading(true);
setBrowserError(null);
try {
const res = await fetch(`${API_URL}/api/browse?host=${selectedHost}&path=${encodeURIComponent(path)}`);
const data = await res.json();
if (data.error) {
setBrowserError(data.error);
return;
}
setBrowserPath(data.currentPath);
setBrowserDirs(data.directories || []);
} catch (err) {
setBrowserError(err.message);
} finally {
setBrowserLoading(false);
}
}, [selectedHost]);
// Open browser
const openBrowser = useCallback(() => {
setShowBrowser(true);
setDropdownOpen(false);
browsePath('~');
}, [browsePath]);
// Get display name for path
const getDisplayName = (path) => {
const parts = path.split('/');
return parts[parts.length - 1] || path;
};
return (
@@ -80,87 +151,106 @@ export function Sidebar({
Host
</h3>
<div className="flex gap-2 flex-wrap">
{hosts.map((host) => (
<button
key={host.id}
onClick={() => onSelectHost(host.id)}
className={`
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
transition-colors border
${selectedHost === host.id
? 'border-orange-500/50 text-white'
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
}
`}
style={{
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
}}
>
<Server className="w-3.5 h-3.5" style={{ color: host.color }} />
<span>{host.name}</span>
{!host.isLocal && <span className="text-xs text-dark-500">(SSH)</span>}
</button>
))}
{hosts.map((host) => {
const isActive = sessionActive && activeHost === host.id;
const isDisabled = sessionActive && activeHost && activeHost !== host.id;
return (
<button
key={host.id}
onClick={() => !isDisabled && onSelectHost(host.id)}
disabled={isDisabled}
className={`
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
transition-colors border relative
${isDisabled
? 'border-dark-800 text-dark-600 cursor-not-allowed opacity-50'
: selectedHost === host.id
? 'border-orange-500/50 text-white'
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
}
`}
style={{
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
}}
>
<Server className="w-3.5 h-3.5" style={{ color: isDisabled ? '#4a4a4a' : host.color }} />
<span>{host.name}</span>
{isActive && (
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" title="Active session" />
)}
</button>
);
})}
</div>
{sessionActive && (
<p className="text-xs text-dark-500">Stop session to switch hosts</p>
)}
</div>
{/* Project Selection */}
{/* Working Directory */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
Working Directory
</h3>
<div className="space-y-1">
{projects.map((project) => (
<button
key={project.path}
onClick={() => onSelectProject(project.path)}
className={`
w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left
transition-colors text-sm
${selectedProject === project.path
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'hover:bg-dark-800 text-dark-300'
}
`}
>
<FolderOpen className="w-4 h-4 flex-shrink-0" />
<div className="min-w-0">
<div className="font-medium truncate">{project.name}</div>
<div className="text-xs text-dark-500 truncate">{project.path}</div>
</div>
{selectedProject === project.path && (
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
)}
</button>
))}
</div>
{/* Custom path input */}
<div className="pt-2">
{/* Directory selector with dropdown */}
<div className="relative">
<div className="flex gap-2">
<input
type="text"
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCustomPath()}
placeholder="Custom path..."
className="flex-1 bg-dark-800 border border-dark-700 rounded-lg px-3 py-2
text-sm text-dark-200 placeholder-dark-500
focus:outline-none focus:border-orange-500/50"
/>
{/* Dropdown button */}
<button
onClick={handleCustomPath}
className="px-3 py-2 bg-dark-800 hover:bg-dark-700 rounded-lg
text-dark-400 hover:text-dark-200 transition-colors"
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex-1 flex items-center gap-2 px-3 py-2.5 bg-dark-800 border border-dark-700 rounded-lg text-left hover:border-dark-600 transition-colors"
>
Set
<FolderOpen className="w-4 h-4 text-orange-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-dark-200 truncate">{getDisplayName(selectedProject)}</div>
<div className="text-xs text-dark-500 truncate">{selectedProject}</div>
</div>
<ChevronDown className={`w-4 h-4 text-dark-500 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
</button>
{/* Browse button */}
<button
onClick={openBrowser}
className="px-3 py-2.5 bg-dark-800 hover:bg-dark-700 border border-dark-700 rounded-lg text-dark-400 hover:text-dark-200 transition-colors"
title="Browse directories"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Dropdown menu */}
{dropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-800 border border-dark-700 rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
{recentDirs.length === 0 ? (
<div className="px-3 py-4 text-sm text-dark-500 text-center">
No recent directories
</div>
) : (
recentDirs.map((path) => (
<button
key={path}
onClick={() => handleSelectDir(path)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-dark-700 transition-colors
${selectedProject === path ? 'bg-orange-500/10 text-orange-400' : 'text-dark-300'}`}
>
<Folder className="w-4 h-4 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{getDisplayName(path)}</div>
<div className="text-xs text-dark-500 truncate">{path}</div>
</div>
{selectedProject === path && (
<ChevronRight className="w-4 h-4 flex-shrink-0" />
)}
</button>
))
)}
</div>
)}
</div>
{/* Resume toggle */}
<div className="pt-3">
<div className="pt-2">
<label className="flex items-center gap-3 cursor-pointer group">
<div
onClick={onToggleResume}
@@ -230,6 +320,84 @@ export function Sidebar({
<div>Claude Code Web UI POC</div>
<div>JSON Stream Mode</div>
</div>
{/* Directory Browser Modal */}
{showBrowser && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-900 border border-dark-700 rounded-xl shadow-2xl w-full max-w-md max-h-[70vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<h3 className="font-semibold text-dark-200">Browse Directories</h3>
<button
onClick={() => setShowBrowser(false)}
className="p-1 hover:bg-dark-700 rounded transition-colors text-dark-400 hover:text-dark-200"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Current path */}
<div className="px-4 py-2 bg-dark-800/50 border-b border-dark-700 flex items-center gap-2">
<span className="text-xs text-dark-500">Path:</span>
<code className="text-xs text-orange-400 flex-1 truncate">{browserPath}</code>
<button
onClick={() => handleSelectDir(browserPath)}
className="px-2 py-1 text-xs bg-orange-600 hover:bg-orange-500 rounded transition-colors"
>
Select
</button>
</div>
{/* Directory list */}
<div className="flex-1 overflow-y-auto p-2">
{browserLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-orange-400 animate-spin" />
</div>
) : browserError ? (
<div className="text-red-400 text-sm text-center py-4">
{browserError}
</div>
) : browserDirs.length === 0 ? (
<div className="text-dark-500 text-sm text-center py-4">
No subdirectories found
</div>
) : (
<div className="space-y-1">
{browserDirs.map((dir) => (
<button
key={dir.path}
onClick={() => browsePath(dir.path)}
onDoubleClick={() => handleSelectDir(dir.path)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-dark-700 transition-colors text-left"
>
{dir.type === 'parent' ? (
<ArrowUp className="w-4 h-4 text-dark-500" />
) : (
<Folder className="w-4 h-4 text-orange-400" />
)}
<span className="text-sm text-dark-200">{dir.name}</span>
</button>
))}
</div>
)}
</div>
{/* Footer hint */}
<div className="px-4 py-2 border-t border-dark-700 text-xs text-dark-500">
Click to navigate, double-click to select
</div>
</div>
</div>
)}
{/* Click outside to close dropdown */}
{dropdownOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setDropdownOpen(false)}
/>
)}
</aside>
);
}

View File

@@ -0,0 +1,177 @@
import { useState } from 'react';
import {
Coins, MessageSquare, Database, Zap, Loader2,
Brain, ShieldCheck, FileEdit, ChevronDown
} from 'lucide-react';
// Permission mode definitions with display info
const PERMISSION_MODES = [
{ value: 'default', label: 'Default', icon: ShieldCheck, color: 'text-blue-400', description: 'Prompts for dangerous tools' },
{ value: 'acceptEdits', label: 'Accept Edits', icon: FileEdit, color: 'text-green-400', description: 'Auto-accept file edits' },
{ value: 'plan', label: 'Plan', icon: Brain, color: 'text-purple-400', description: 'Planning mode only' },
{ value: 'bypassPermissions', label: 'Bypass', icon: Zap, color: 'text-orange-400', description: 'Allow all tools (careful!)' },
];
export function StatusBar({ sessionStats, isProcessing, connected, permissionMode = 'default', controlInitialized, onChangeMode }) {
const [showModeMenu, setShowModeMenu] = useState(false);
const {
totalCost = 0,
inputTokens = 0,
outputTokens = 0,
cacheReadTokens = 0,
cacheCreationTokens = 0,
numTurns = 0,
isCompacting = false,
} = sessionStats || {};
// Get current mode info
const currentMode = PERMISSION_MODES.find(m => m.value === permissionMode) || PERMISSION_MODES[0];
const ModeIcon = currentMode.icon;
// Calculate total tokens and estimate context usage
const totalTokens = inputTokens + outputTokens;
// Claude has ~200k context, but we show relative usage
const contextPercent = Math.min(100, (inputTokens / 200000) * 100);
// Format cost
const formatCost = (cost) => {
if (cost < 0.01) return `$${cost.toFixed(4)}`;
if (cost < 1) return `$${cost.toFixed(3)}`;
return `$${cost.toFixed(2)}`;
};
// Format token count
const formatTokens = (tokens) => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
return tokens.toString();
};
return (
<div className="bg-dark-900 border-t border-dark-700 px-4 py-2">
<div className="flex items-center justify-between text-xs">
{/* Left side: Stats */}
<div className="flex items-center gap-4">
{/* Connection status */}
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-dark-400">{connected ? 'Connected' : 'Disconnected'}</span>
</div>
{/* Processing indicator */}
{isProcessing && (
<div className="flex items-center gap-1.5 text-orange-400">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Processing...</span>
</div>
)}
{/* Compacting indicator */}
{isCompacting && (
<div className="flex items-center gap-1.5 text-purple-400">
<Zap className="w-3 h-3 animate-pulse" />
<span>Compacting context...</span>
</div>
)}
{/* Turns */}
{numTurns > 0 && (
<div className="flex items-center gap-1.5 text-dark-400">
<MessageSquare className="w-3 h-3" />
<span>{numTurns} turns</span>
</div>
)}
{/* Cost */}
{totalCost > 0 && (
<div className="flex items-center gap-1.5 text-dark-400">
<Coins className="w-3 h-3" />
<span>{formatCost(totalCost)}</span>
</div>
)}
{/* Permission Mode Toggle - show always for debugging, just disabled when not initialized */}
{(
<div className="relative">
<button
onClick={() => setShowModeMenu(!showModeMenu)}
className={`flex items-center gap-1.5 px-2 py-0.5 rounded ${currentMode.color} hover:bg-dark-800 transition-colors`}
title={currentMode.description}
>
<ModeIcon className="w-3 h-3" />
<span>{currentMode.label}</span>
<ChevronDown className={`w-3 h-3 transition-transform ${showModeMenu ? 'rotate-180' : ''}`} />
</button>
{showModeMenu && (
<div className="absolute bottom-full left-0 mb-1 bg-dark-800 border border-dark-600 rounded shadow-lg py-1 min-w-[160px] z-50">
{PERMISSION_MODES.map((mode) => {
const Icon = mode.icon;
const isActive = mode.value === permissionMode;
return (
<button
key={mode.value}
onClick={() => {
onChangeMode(mode.value);
setShowModeMenu(false);
}}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-dark-700 transition-colors ${
isActive ? mode.color : 'text-dark-300'
}`}
>
<Icon className="w-3.5 h-3.5" />
<div>
<div className="text-xs font-medium">{mode.label}</div>
<div className="text-[10px] text-dark-500">{mode.description}</div>
</div>
</button>
);
})}
</div>
)}
</div>
)}
</div>
{/* Right side: Token usage */}
<div className="flex items-center gap-4">
{/* Token counts */}
{totalTokens > 0 && (
<div className="flex items-center gap-3 text-dark-400">
<span className="flex items-center gap-1">
<span className="text-dark-500">In:</span>
<span className="text-cyan-400">{formatTokens(inputTokens)}</span>
</span>
<span className="flex items-center gap-1">
<span className="text-dark-500">Out:</span>
<span className="text-green-400">{formatTokens(outputTokens)}</span>
</span>
{cacheReadTokens > 0 && (
<span className="flex items-center gap-1">
<Database className="w-3 h-3 text-purple-400" />
<span className="text-purple-400">{formatTokens(cacheReadTokens)}</span>
</span>
)}
</div>
)}
{/* Context status - simple text based on remaining context */}
<div className="flex items-center gap-2">
<span className="text-dark-500">Context:</span>
{inputTokens > 0 ? (
<span className={`${
contextPercent >= 95 ? 'text-red-400 font-medium' :
contextPercent >= 85 ? 'text-yellow-400' : 'text-green-400'
}`}>
{contextPercent >= 85 ? `${(100 - contextPercent).toFixed(0)}% left` : 'ok'}
</span>
) : (
<span className="text-green-400">ok</span>
)}
</div>
</div>
</div>
</div>
);
}