Files
claude-web-ui/frontend/src/components/Sidebar.jsx
Nikolas Syring 1186cb1b5e feat: Add OIDC authentication with Authentik integration
- 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>
2025-12-18 06:07:22 +01:00

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