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