- 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>
404 lines
15 KiB
JavaScript
404 lines
15 KiB
JavaScript
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,
|
|
onToggle,
|
|
selectedProject,
|
|
onSelectProject,
|
|
selectedHost,
|
|
onSelectHost,
|
|
sessionActive,
|
|
activeHost,
|
|
onStartSession,
|
|
onStopSession,
|
|
onClearMessages,
|
|
resumeSession,
|
|
onToggleResume
|
|
}) {
|
|
const [hosts, setHosts] = 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(() => {
|
|
fetch(`${API_URL}/api/hosts`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setHosts(data.hosts || []);
|
|
if (!selectedHost && data.defaultHost) {
|
|
onSelectHost(data.defaultHost);
|
|
}
|
|
})
|
|
.catch(console.error);
|
|
}, []);
|
|
|
|
// Load recent directories when host changes
|
|
useEffect(() => {
|
|
if (!selectedHost) return;
|
|
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]);
|
|
|
|
// 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 (
|
|
<aside
|
|
className={`
|
|
${open ? 'w-72' : 'w-0'}
|
|
bg-dark-900 border-r border-dark-800 flex flex-col
|
|
transition-all duration-300 overflow-hidden
|
|
lg:relative fixed inset-y-0 left-0 z-40
|
|
`}
|
|
>
|
|
<div className="p-4 border-b border-dark-800">
|
|
<h2 className="font-semibold text-dark-200 flex items-center gap-2">
|
|
<Settings className="w-4 h-4" />
|
|
Session Control
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
|
{/* Host Selection */}
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
|
Host
|
|
</h3>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{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>
|
|
|
|
{/* Working Directory */}
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
|
Working Directory
|
|
</h3>
|
|
|
|
{/* Directory selector with dropdown */}
|
|
<div className="relative">
|
|
<div className="flex gap-2">
|
|
{/* Dropdown button */}
|
|
<button
|
|
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"
|
|
>
|
|
<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-2">
|
|
<label className="flex items-center gap-3 cursor-pointer group">
|
|
<div
|
|
onClick={onToggleResume}
|
|
className={`
|
|
relative w-10 h-5 rounded-full transition-colors
|
|
${resumeSession ? 'bg-orange-600' : 'bg-dark-700'}
|
|
`}
|
|
>
|
|
<div
|
|
className={`
|
|
absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform
|
|
${resumeSession ? 'translate-x-5' : 'translate-x-0.5'}
|
|
`}
|
|
/>
|
|
</div>
|
|
<span className="text-sm text-dark-300 group-hover:text-dark-200">
|
|
Resume previous session
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Session Actions */}
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
|
Actions
|
|
</h3>
|
|
|
|
<div className="space-y-2">
|
|
{!sessionActive ? (
|
|
<button
|
|
onClick={onStartSession}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
|
bg-green-600 hover:bg-green-500 rounded-lg
|
|
font-medium transition-colors"
|
|
>
|
|
<Play className="w-4 h-4" />
|
|
Start Session
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={onStopSession}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
|
bg-red-600 hover:bg-red-500 rounded-lg
|
|
font-medium transition-colors"
|
|
>
|
|
<Square className="w-4 h-4" />
|
|
Stop Session
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={onClearMessages}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2
|
|
bg-dark-800 hover:bg-dark-700 rounded-lg
|
|
text-dark-300 hover:text-dark-100 transition-colors"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Clear Messages
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-dark-800 text-xs text-dark-500">
|
|
<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>
|
|
);
|
|
}
|