- Add OIDC login flow with Authentik provider - Implement session-based auth with Redis store - Add avatar display from OIDC claims - Fix input field performance with react-textarea-autosize - Stabilize callbacks to prevent unnecessary re-renders - Fix history loading to skip empty session files - Add 2-row default height for input textarea 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
520 lines
19 KiB
JavaScript
520 lines
19 KiB
JavaScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2, LogOut, User, Shield } from 'lucide-react';
|
|
import { useSessionManager } from '../contexts/SessionContext';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
|
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] || [];
|
|
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 }) {
|
|
const {
|
|
focusedSessionId,
|
|
focusedSession,
|
|
startClaudeSession,
|
|
stopClaudeSession,
|
|
clearMessages,
|
|
updateSessionConfig,
|
|
} = useSessionManager();
|
|
|
|
const { user, authEnabled, logout, isAdmin } = useAuth();
|
|
|
|
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);
|
|
|
|
// Current session values
|
|
const currentHost = focusedSession?.host || 'neko';
|
|
const currentProject = focusedSession?.project || '/home/sumdex/projects';
|
|
const sessionActive = focusedSession?.active || false;
|
|
const resumeSession = focusedSession?.resumeOnStart ?? true;
|
|
|
|
// Fetch hosts on mount
|
|
useEffect(() => {
|
|
fetch('/api/hosts', { credentials: 'include' })
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setHosts(data.hosts || []);
|
|
})
|
|
.catch(console.error);
|
|
}, []);
|
|
|
|
// Load recent directories when host changes
|
|
useEffect(() => {
|
|
if (!currentHost) return;
|
|
const recent = loadRecentDirs();
|
|
setRecentDirs(recent[currentHost] || []);
|
|
}, [currentHost]);
|
|
|
|
// Handle selecting a directory
|
|
const handleSelectDir = useCallback((path) => {
|
|
if (!focusedSessionId) return;
|
|
updateSessionConfig(focusedSessionId, { project: path });
|
|
const updated = addRecentDir(currentHost, path);
|
|
setRecentDirs(updated);
|
|
setDropdownOpen(false);
|
|
setShowBrowser(false);
|
|
}, [focusedSessionId, currentHost, updateSessionConfig]);
|
|
|
|
// Handle host change
|
|
const handleSelectHost = useCallback((hostId) => {
|
|
if (!focusedSessionId || sessionActive) return;
|
|
updateSessionConfig(focusedSessionId, { host: hostId });
|
|
}, [focusedSessionId, sessionActive, updateSessionConfig]);
|
|
|
|
// Handle resume toggle
|
|
const handleToggleResume = useCallback(() => {
|
|
if (!focusedSessionId) return;
|
|
updateSessionConfig(focusedSessionId, { resumeOnStart: !resumeSession });
|
|
}, [focusedSessionId, resumeSession, updateSessionConfig]);
|
|
|
|
// Browse directories on host
|
|
const browsePath = useCallback(async (path) => {
|
|
if (!currentHost) return;
|
|
setBrowserLoading(true);
|
|
setBrowserError(null);
|
|
|
|
try {
|
|
const res = await fetch(`/api/browse?host=${currentHost}&path=${encodeURIComponent(path)}`, {
|
|
credentials: 'include',
|
|
});
|
|
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);
|
|
}
|
|
}, [currentHost]);
|
|
|
|
// 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;
|
|
};
|
|
|
|
// Start/stop session handlers
|
|
const handleStartSession = useCallback(() => {
|
|
if (focusedSessionId) {
|
|
startClaudeSession(focusedSessionId);
|
|
}
|
|
}, [focusedSessionId, startClaudeSession]);
|
|
|
|
const handleStopSession = useCallback(() => {
|
|
if (focusedSessionId) {
|
|
stopClaudeSession(focusedSessionId);
|
|
}
|
|
}, [focusedSessionId, stopClaudeSession]);
|
|
|
|
const handleClearMessages = useCallback(() => {
|
|
if (focusedSessionId) {
|
|
clearMessages(focusedSessionId);
|
|
}
|
|
}, [focusedSessionId, clearMessages]);
|
|
|
|
// No session selected
|
|
if (!focusedSession) {
|
|
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 flex items-center justify-center p-4">
|
|
<p className="text-dark-500 text-sm text-center">
|
|
Create or select a session tab to configure
|
|
</p>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
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 isSelected = currentHost === host.id;
|
|
const isDisabled = sessionActive && currentHost !== host.id;
|
|
return (
|
|
<button
|
|
key={host.id}
|
|
onClick={() => !isDisabled && handleSelectHost(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'
|
|
: isSelected
|
|
? 'border-orange-500/50 text-white'
|
|
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
|
|
}
|
|
`}
|
|
style={{
|
|
backgroundColor: isSelected ? `${host.color}30` : 'transparent'
|
|
}}
|
|
>
|
|
<Server className="w-3.5 h-3.5" style={{ color: isDisabled ? '#4a4a4a' : host.color }} />
|
|
<span>{host.name}</span>
|
|
{sessionActive && isSelected && (
|
|
<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={() => !sessionActive && setDropdownOpen(!dropdownOpen)}
|
|
disabled={sessionActive}
|
|
className={`
|
|
flex-1 flex items-center gap-2 px-3 py-2.5 bg-dark-800 border border-dark-700 rounded-lg text-left transition-colors
|
|
${sessionActive ? 'opacity-50 cursor-not-allowed' : 'hover:border-dark-600'}
|
|
`}
|
|
>
|
|
<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(currentProject)}</div>
|
|
<div className="text-xs text-dark-500 truncate">{currentProject}</div>
|
|
</div>
|
|
<ChevronDown className={`w-4 h-4 text-dark-500 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{/* Browse button */}
|
|
<button
|
|
onClick={openBrowser}
|
|
disabled={sessionActive}
|
|
className={`
|
|
px-3 py-2.5 bg-dark-800 border border-dark-700 rounded-lg text-dark-400 transition-colors
|
|
${sessionActive ? 'opacity-50 cursor-not-allowed' : 'hover:bg-dark-700 hover:text-dark-200'}
|
|
`}
|
|
title="Browse directories"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Dropdown menu */}
|
|
{dropdownOpen && !sessionActive && (
|
|
<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
|
|
${currentProject === 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>
|
|
{currentProject === path && (
|
|
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
|
)}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{sessionActive && (
|
|
<p className="text-xs text-dark-500">Stop session to change directory</p>
|
|
)}
|
|
|
|
{/* Resume toggle */}
|
|
<div className="pt-2">
|
|
<label className="flex items-center gap-3 cursor-pointer group">
|
|
<div
|
|
onClick={handleToggleResume}
|
|
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={handleStartSession}
|
|
disabled={!focusedSession?.connected}
|
|
className={`
|
|
w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium transition-colors
|
|
${focusedSession?.connected
|
|
? 'bg-green-600 hover:bg-green-500'
|
|
: 'bg-dark-700 text-dark-500 cursor-not-allowed'}
|
|
`}
|
|
>
|
|
<Play className="w-4 h-4" />
|
|
{focusedSession?.connected ? 'Start Session' : 'Connecting...'}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleStopSession}
|
|
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={handleClearMessages}
|
|
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>
|
|
|
|
{/* User & Footer */}
|
|
<div className="border-t border-dark-800">
|
|
{/* User info */}
|
|
{authEnabled && user && (
|
|
<div className="p-4 border-b border-dark-800">
|
|
<div className="flex items-center gap-3">
|
|
{user.avatar ? (
|
|
<img
|
|
src={user.avatar}
|
|
alt={user.name || user.email}
|
|
className="w-8 h-8 rounded-full flex-shrink-0 object-cover"
|
|
onError={(e) => {
|
|
e.target.style.display = 'none';
|
|
e.target.nextSibling.style.display = 'flex';
|
|
}}
|
|
/>
|
|
) : null}
|
|
<div
|
|
className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center flex-shrink-0"
|
|
style={{ display: user.avatar ? 'none' : 'flex' }}
|
|
>
|
|
<User className="w-4 h-4 text-dark-400" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm text-dark-200 truncate">{user.name || user.email}</div>
|
|
<div className="flex items-center gap-1 text-xs text-dark-500">
|
|
{isAdmin && <Shield className="w-3 h-3 text-orange-400" />}
|
|
<span className="truncate">{user.email}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={logout}
|
|
className="p-2 hover:bg-dark-700 rounded-lg text-dark-400 hover:text-red-400 transition-colors"
|
|
title="Sign out"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 text-xs text-dark-500">
|
|
<div>Claude Code Web UI</div>
|
|
<div>Multi-Session Mode</div>
|
|
</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>
|
|
);
|
|
}
|