chat client UI restructure + added all projects view and settings view(placeholder)
This commit is contained in:
359
packages/chat-client/src/components/Sidebar.jsx
Normal file
359
packages/chat-client/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { useState } from 'react';
|
||||
import SessionModal from './SessionModal';
|
||||
import { useContextMenu } from '../hooks/useContextMenu';
|
||||
import { renameSession, deleteSession } from '../api/orchestration';
|
||||
|
||||
|
||||
export default function Sidebar({
|
||||
sessions,
|
||||
activeSession,
|
||||
onSelectSession,
|
||||
onNewChat,
|
||||
onNewProject,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onSessionsChange,
|
||||
onNavigate,
|
||||
projects,
|
||||
onProjectsChange,
|
||||
}) {
|
||||
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) {
|
||||
try {
|
||||
await renameSession(session.external_id, name);
|
||||
onSessionsChange();
|
||||
} catch (err) {
|
||||
console.error('[Sidebar] Rename failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(session) {
|
||||
try {
|
||||
await deleteSession(session.external_id);
|
||||
onSessionsChange(session);
|
||||
} catch (err) {
|
||||
console.error('[Sidebar] Delete failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getPreview(session) {
|
||||
if (session.isNew) return 'New conversation';
|
||||
return session.name || session.external_id;
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
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: 500, 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-muted)',
|
||||
fontSize: '12px',
|
||||
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={() => onNavigate('all-projects')}
|
||||
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.map(session => (
|
||||
<SessionRow
|
||||
key={session.external_id}
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{recentSessions.length === 0 && (
|
||||
<div className="text-xs text-muted" style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
No conversations yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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: 'space-between',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span style={{ fontSize: '10px' }}>{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',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="btn-reset"
|
||||
style={{
|
||||
flex: 1, padding: '8px 16px',
|
||||
paddingRight: isHovered && !session.isNew ? '4px' : '16px',
|
||||
textAlign: 'left', minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{isHovered && !session.isNew && (
|
||||
<div className="flex items-center flex-shrink" style={{ gap: '2px', paddingRight: '8px' }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user