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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
202
frontend/src/components/PermissionDialog.jsx
Normal file
202
frontend/src/components/PermissionDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
177
frontend/src/components/StatusBar.jsx
Normal file
177
frontend/src/components/StatusBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user