Files
nexusAI/packages/chat-client/src/components/Sidebar.jsx
2026-04-26 22:28:54 -07:00

425 lines
16 KiB
JavaScript

import React, { useState } from 'react';
import SessionModal from './SessionModal';
import { useContextMenu } from '../hooks/useContextMenu';
import { renameSession, deleteSession, updateSession } from '../api/orchestration';
const { logger } = require('@nexusai/shared');
export default function Sidebar({
sessions,
activeSession,
onSelectSession,
onNewChat,
onNewProject,
isOpen,
onToggle,
onSessionsChange,
onNavigate,
projects,
onProjectsChange,
onSelectProject
}) {
const [chatsOpen, setChatsOpen] = useState(true);
const [projectsOpen, setProjectsOpen] = useState(true);
const [modalSession, setModalSession] = useState(null);
const [modalMode, setModalMode] = useState('settings');
const [hoveredId, setHoveredId] = useState(null);
const { menu, open: openMenu, close: closeMenu } = useContextMenu();
// ── Handlers ────────────────────────────────────────────
async function handleRename(session, name, projectId) {
try {
await updateSession(session.external_id, { name, projectId });
onSessionsChange();
} catch (err) {
logger.error('[Sidebar] Rename failed:', err.message);
}
}
async function handleDelete(session) {
try {
await deleteSession(session.external_id);
onSessionsChange(session);
} catch (err) {
logger.error('[Sidebar] Delete failed:', err.message);
}
}
// ── Collapsed rail ───────────────────────────────────────
if (!isOpen) {
return (
<div className="flex-col" style={{
width: '48px',
flexShrink: 0,
background: 'var(--bg-surface)',
borderRight: '1px solid var(--border)',
alignItems: 'center',
paddingTop: '8px',
paddingBottom: '8px',
gap: '4px',
}}>
{/* Expand toggle */}
<button className="btn-icon" onClick={onToggle} title="Expand sidebar"
style={{ marginBottom: '4px' }}></button>
<div style={{ width: '32px', height: '1px', background: 'var(--border)', margin: '4px 0' }} />
{/* New Chat */}
<button className="btn-icon" onClick={onNewChat} title="New Chat"
style={{ fontSize: '18px', color: 'var(--text-secondary)' }}>+</button>
{/* New Project */}
<button className="btn-icon" onClick={onNewProject} title="View Projects"
style={{ fontSize: '14px', color: 'var(--text-secondary)' }}></button>
{/* All Chats */}
<button className="btn-icon" onClick={() => onNavigate('all-chats')} title="All Chats"
style={{ fontSize: '14px', color: 'var(--text-secondary)' }}></button>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Settings */}
<button className="btn-icon" onClick={() => onNavigate('settings')} title="Settings"
style={{ fontSize: '14px', color: 'var(--text-secondary)' }}></button>
</div>
);
}
// ── Expanded sidebar ─────────────────────────────────────
const recentSessions = sessions.slice(0, 10);
// Group recent sessions by project
const grouped = {};
const unassigned = [];
for (const session of recentSessions) {
if (session.project_id) {
if (!grouped[session.project_id]) grouped[session.project_id] = [];
grouped[session.project_id].push(session);
} else {
unassigned.push(session);
}
}
const sessionRowProps = (session) => ({
session,
isActive: activeSession?.external_id === session.external_id,
isHovered: hoveredId === session.external_id,
onHover: setHoveredId,
onSelect: () => { onSelectSession(session); onNavigate('chat'); },
onRename: () => { setModalMode('settings'); setModalSession(session); },
onDelete: () => { setModalMode('confirm-delete'); setModalSession(session); },
onContextMenu: e => !session.isNew && openMenu(e, session),
});
return (
<>
<div className="flex-col" style={{
width: 'var(--sidebar-width)',
flexShrink: 0,
background: 'var(--bg-surface)',
borderRight: '1px solid var(--border)',
overflow: 'hidden',
}}>
{/* Header */}
<div className="panel-header" style={{ justifyContent: 'space-between', padding: '0 12px 0 16px' }}>
<span className="text-base" style={{ fontWeight: 1000, color: 'var(--text-secondary)' }}>NexusAI</span>
<button className="btn-icon" onClick={onToggle}></button>
</div>
{/* Action buttons */}
<div style={{ padding: '10px 10px 6px', display: 'flex', flexDirection: 'column', gap: '6px', flexShrink: 0 }}>
<button className="btn-primary" onClick={onNewChat} style={{
width: '100%', padding: '7px 12px',
display: 'flex', alignItems: 'center', gap: '8px',
}}>
<span style={{ fontSize: '16px', lineHeight: 1 }}>+</span>
<span>New Chat</span>
</button>
<button className="btn-primary" onClick={onNewProject} style={{
width: '100%', padding: '7px 12px',
display: 'flex', alignItems: 'center', gap: '8px',
}}>
<span style={{ fontSize: '14px', lineHeight: 1 }}></span>
<span>View Projects</span>
</button>
</div>
<div style={{ height: '1px', background: 'var(--border)', flexShrink: 0, margin: '2px 0' }} />
{/* Scrollable content */}
<div className="flex-1 scroll-y">
{/* ── Projects section ── */}
<SectionHeader
label="Projects"
isOpen={projectsOpen}
onToggle={() => setProjectsOpen(o => !o)}
/>
{projectsOpen && (
<div style={{ padding: '4px 10px 8px' }}>
{!projects?.length ? (
<div style={{
padding: '10px',
borderRadius: 'var(--radius-md)',
border: '1px dashed var(--border)',
color: 'var(--text-sb-hdr)',
fontSize: '13px',
textAlign: 'center',
}}>
No projects yet
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{projects.slice(0, 6).map(project => (
<button
key={project.id}
onClick={() => { onSelectProject(project); onNavigate('project'); }}
className="btn-reset text-xs"
style={{
padding: '4px 8px',
borderRadius: 'var(--radius-sm)',
background: 'var(--bg-elevated)',
border: `1px solid ${project.colour ?? 'var(--border)'}`,
color: 'var(--text-secondary)',
maxWidth: '100%',
}}
title={project.description ?? project.name}
>
<span className="truncate" style={{ display: 'block', maxWidth: '140px' }}>
{project.name}
</span>
</button>
))}
</div>
)}
</div>
)}
<div style={{ height: '1px', background: 'var(--border)', margin: '2px 0' }} />
{/* ── Recent Chats section ── */}
<SectionHeader
label="Recent Chats"
isOpen={chatsOpen}
onToggle={() => setChatsOpen(o => !o)}
/>
{chatsOpen && (
<>
{recentSessions.length === 0 && (
<div className="text-xs text-muted" style={{ padding: '12px 16px', textAlign: 'center' }}>
No conversations yet
</div>
)}
{/* Project groups */}
{Object.entries(grouped).map(([projectId, projectSessions]) => {
const project = projects?.find(p => p.id === Number(projectId));
return (
<div key={projectId}>
{/* Project group label */}
<div style={{
display: 'flex', alignItems: 'center', gap: '6px',
padding: '6px 16px 2px',
}}>
<span className=" text-muted truncate"
style={{
fontSize: '12px',
textTransform: 'uppercase',
fontWeight: '500',
textAlign: 'center',
borderRadius: 'var(--radius-md)',
border: `1px solid ${project.colour ?? 'var(--border)'}`,
padding: '2px 2px',
width: '100%'
}}>
{project?.name ?? 'Project'}
</span>
</div>
{projectSessions.map(session => (
<SessionRow key={session.external_id} {...sessionRowProps(session)} />
))}
</div>
);
})}
{/* Unassigned sessions */}
{unassigned.length > 0 && (
<>
{Object.keys(grouped).length > 0 && (
<div style={{ padding: '6px 16px 2px' }}>
<span className=" text-muted " style={{fontSize: '12px', textTransform: 'uppercase', fontWeight: '500', textAlign: 'center',}}>Other</span>
</div>
)}
{unassigned.map(session => (
<SessionRow key={session.external_id} {...sessionRowProps(session)} />
))}
</>
)}
{sessions.length > 0 && (
<button
onClick={() => onNavigate('all-chats')}
className="btn-reset text-xs text-muted"
style={{ width: '100%', padding: '6px', borderRadius: 'var(--radius-sm)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
>
All Chats
</button>
)}
</>
)}
</div>
{/* Settings — pinned to bottom */}
<div style={{ borderTop: '1px solid var(--border)', padding: '8px 10px', flexShrink: 0 }}>
<button
onClick={() => onNavigate('settings')}
className="btn-reset text-base"
style={{
width: '100%', padding: '8px 12px',
borderRadius: 'var(--radius-md)',
display: 'flex', alignItems: 'center', gap: '8px',
color: 'var(--text-secondary)',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-elevated)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<span style={{ fontSize: '14px' }}></span>
<span>Settings</span>
</button>
</div>
</div>
{/* Context menu */}
{menu && (
<div
onClick={e => e.stopPropagation()}
style={{
position: 'fixed', top: menu.y, left: menu.x,
background: 'var(--bg-elevated)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)', padding: '4px', zIndex: 50, minWidth: '140px',
}}
>
<ContextMenuItem
onClick={() => { setModalMode('settings'); setModalSession(menu.session); closeMenu(); }}
> Rename</ContextMenuItem>
<ContextMenuItem
onClick={() => { setModalMode('confirm-delete'); setModalSession(menu.session); closeMenu(); }}
danger
> Delete</ContextMenuItem>
</div>
)}
{/* Session modal */}
{modalSession && (
<SessionModal
session={modalSession}
mode={modalMode}
onRename={handleRename}
onDelete={handleDelete}
onClose={() => setModalSession(null)}
projects={projects}
/>
)}
</>
);
}
// ── Sub-components ───────────────────────────────────────────
function SectionHeader({ label, isOpen, onToggle }) {
return (
<button
onClick={onToggle}
className="btn-reset label-upper"
style={{
width: '100%', padding: '8px 16px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-sb-hdr)',
}}
>
<span>{label}</span>
<span style={{ fontSize: '13px' }}>{isOpen ? '▾' : '▸'}</span>
</button>
);
}
function SessionRow({ session, isActive, isHovered, onHover, onSelect, onRename, onDelete, onContextMenu }) {
return (
<div
onMouseEnter={() => onHover(session.external_id)}
onMouseLeave={() => onHover(null)}
onContextMenu={onContextMenu}
style={{
position: 'relative', display: 'flex', alignItems: 'stretch',
background: isActive || isHovered ? 'var(--bg-elevated)' : 'transparent',
borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
transition: 'background 0.1s',
overflow: 'hidden',
width: '100%',
boxSizing: 'border-box',
}}
>
<button
onClick={onSelect}
className="btn-reset"
style={{
flex: 1, padding: '8px 16px',
paddingRight: isHovered && !session.isNew ? '4px' : '16px',
textAlign: 'left',
minWidth: 0,
overflow: 'hidden',
}}
>
<span className="text-base truncate" style={{
display: 'block',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 500 : 400,
}}>
{session.isNew ? 'New conversation' : (session.name || session.external_id)}
</span>
{session.isNew && (
<span className="text-xs text-accent" style={{ fontStyle: 'italic' }}>Unsaved</span>
)}
</button>
<div
style={{
display: 'flex', alignItems: 'center',
gap: '2px',
paddingRight: isHovered && !session.isNew ? '8px' : '0px',
flexShrink: 0,
width: isHovered && !session.isNew ? '44px' : '0px',
overflow: 'hidden',
transition: 'width 0.1s ease',
}}
>
<button className="btn-icon" title="Rename" onClick={onRename}
style={{ padding: '2px 4px', fontSize: '12px' }}></button>
<button className="btn-icon" title="Delete" onClick={onDelete}
style={{ padding: '2px 4px', fontSize: '12px', color: '#ff6b6b' }}></button>
</div>
</div>
);
}
function ContextMenuItem({ children, onClick, danger }) {
return (
<button
className="btn-reset text-base"
onClick={onClick}
style={{ width: '100%', padding: '8px 12px', borderRadius: 'var(--radius-sm)', justifyContent: 'flex-start', color: danger ? '#ff6b6b' : 'var(--text-primary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-surface)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>{children}</button>
);
}