chat client UI restructure + added all projects view and settings view(placeholder)

This commit is contained in:
Storme-bit
2026-04-13 17:08:52 -07:00
parent 7501fc54f1
commit 699592071f
10 changed files with 1077 additions and 27 deletions

View File

@@ -1,15 +1,25 @@
import React, { useState } from 'react';
import SessionList from './components/SessionList';
import ChatWindow from './components/ChatWindow';
import InfoPanel from './components/InfoPanel';
import Sidebar from './components/Sidebar';
/*** View Panels*** */
import AllChatsView from './components/AllChatsView';
import AllProjectsView from './components/AllProjectsView';
import SettingsView from './components/SettingsView';
/**** useHooks **** */
import { useSession } from './hooks/useSession';
import { useChat } from './hooks/useChat';
import { useModels } from './hooks/useModels';
import { useProjects } from './hooks/useProjects';
export default function App() {
const [leftOpen, setLeftOpen] = useState(true);
const [rightOpen, setRightOpen] = useState(true);
const [rightOpen, setRightOpen] = useState(false);
const { models, selectedModel, setSelectedModel } = useModels();
const [view, setView] = useState('chat')
const {projects, refreshProjects} = useProjects();
const {
sessions,
@@ -48,24 +58,45 @@ export default function App() {
height: '100vh',
overflow: 'hidden',
}}>
<SessionList
<Sidebar
sessions={sessions}
activeSession={activeSession}
onSelectSession={selectSession}
onNewChat={createSession}
onNewProject={()=> setView('all-projects')}
isOpen={leftOpen}
onToggle={() => setLeftOpen(o => !o)}
onSessionsChange={handleSessionsChange}
onNavigate={setView}
projects={projects}
onProjectsChange={refreshProjects}
/>
{view === 'chat' && (
<ChatWindow
messages={messages}
loadingHistory={loadingHistory}
streaming={streaming}
activeSession={activeSession}
onSendMessage={handleSendMessage}
onCancel={cancelStream}
/>
messages={messages}
loadingHistory={loadingHistory}
streaming={streaming}
activeSession={activeSession}
onSendMessage={handleSendMessage}
onCancel={cancelStream}
onTogglePanel={() => setRightOpen(o => !o)}
/>
)}
{view === 'all-chats' && (
<AllChatsView
onSelectSession={session => {selectSession(session); setView('chat');}}
/>
)}
{view === 'all-projects' && (
<AllProjectsView onProjectsChange={refreshProjects}/>
)}
{view === 'settings' && <SettingsView />}
<InfoPanel
isOpen={rightOpen}

View File

@@ -159,4 +159,35 @@ export async function deleteSession(sessionId) {
method: 'DELETE',
});
if (!res.ok) throw new Error(`Failed to delete session: ${res.status}`);
}
export async function fetchProjects() {
const res = await fetch(`${BASE_URL}/projects`);
if (!res.ok) throw new Error(`Failed to fetch projects: ${res.status}`);
return res.json();
}
export async function createProject({ name, description, colour, icon }) {
const res = await fetch(`${BASE_URL}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, colour, icon }),
});
if (!res.ok) throw new Error(`Failed to create project: ${res.status}`);
return res.json();
}
export async function updateProject(id, { name, description, colour, icon }) {
const res = await fetch(`${BASE_URL}/projects/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, colour, icon }),
});
if (!res.ok) throw new Error(`Failed to update project: ${res.status}`);
return res.json();
}
export async function deleteProject(id) {
const res = await fetch(`${BASE_URL}/projects/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Failed to delete project: ${res.status}`);
}

View File

@@ -0,0 +1,253 @@
import React, { useState, useEffect } from 'react';
import { fetchSessions, deleteSession } from '../api/orchestration';
const PAGE_SIZE = 20;
export default function AllChatsView({ onSelectSession }) {
const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [selected, setSelected] = useState(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
loadPage(page);
}, [page]);
async function loadPage(p) {
setLoading(true);
setSelected(new Set());
try {
const data = await fetchSessions(PAGE_SIZE, p * PAGE_SIZE);
setSessions(data);
// We don't have a total count from the API yet — infer pagination
// from whether we got a full page back
setTotal(data.length === PAGE_SIZE ? (p + 2) * PAGE_SIZE : p * PAGE_SIZE + data.length);
} catch (err) {
console.error('[AllChatsView] Failed to load sessions:', err.message);
} finally {
setLoading(false);
}
}
function toggleSelect(id) {
setSelected(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function toggleSelectAll() {
if (selected.size === sessions.length) {
setSelected(new Set());
} else {
setSelected(new Set(sessions.map(s => s.external_id)));
}
}
async function handleBulkDelete() {
setDeleting(true);
try {
await Promise.all([...selected].map(id => deleteSession(id)));
setConfirmOpen(false);
await loadPage(page);
} catch (err) {
console.error('[AllChatsView] Bulk delete failed:', err.message);
} finally {
setDeleting(false);
}
}
function formatTimestamp(ts) {
if (!ts) return '—';
const date = new Date(ts * 1000);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays === 1) return 'Yesterday';
// Absolute date past 1 day
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
}
const totalPages = Math.ceil(total / PAGE_SIZE);
const allSelected = sessions.length > 0 && selected.size === sessions.length;
return (
<div className="flex-col flex-1 overflow-hidden" style={{ background: 'var(--bg-base)' }}>
{/* Header */}
<div className="panel-header" style={{ padding: '0 24px', justifyContent: 'space-between' }}>
<span className="text-base" style={{ fontWeight: 500, color: 'var(--text-secondary)' }}>
All Chats
</span>
{selected.size > 0 && (
<button
onClick={() => setConfirmOpen(true)}
className="btn-reset text-xs"
style={{
padding: '4px 10px',
borderRadius: 'var(--radius-md)',
background: '#c0392b22',
color: '#ff6b6b',
border: '1px solid #c0392b55',
}}
>
Delete {selected.size} selected
</button>
)}
</div>
{/* Table */}
<div className="flex-1 scroll-y" style={{ padding: '16px 24px' }}>
{loading ? (
<div className="text-base text-muted" style={{ padding: '40px', textAlign: 'center' }}>
Loading...
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th style={{ width: '36px', padding: '8px 0' }}>
{/* Select all checkbox */}
<input
type="checkbox"
checked={allSelected}
onChange={toggleSelectAll}
style={{ cursor: 'pointer', accentColor: 'var(--accent-hover)' }}
/>
</th>
<th className="label-upper" style={{ textAlign: 'left', padding: '8px 12px' }}>Name</th>
<th className="label-upper" style={{ textAlign: 'right', padding: '8px 0', width: '120px' }}>Last Active</th>
</tr>
</thead>
<tbody>
{sessions.map(session => {
const isSelected = selected.has(session.external_id);
return (
<tr
key={session.external_id}
style={{
borderBottom: '1px solid var(--border)',
background: isSelected ? 'var(--bg-elevated)' : 'transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-surface)'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent'; }}
>
<td style={{ padding: '10px 0', width: '36px' }}>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(session.external_id)}
style={{ cursor: 'pointer', accentColor: 'var(--accent-hover)' }}
/>
</td>
<td style={{ padding: '10px 12px' }}>
<button
className="btn-reset text-base"
onClick={() => onSelectSession(session)}
style={{ color: 'var(--text-primary)', textAlign: 'left' }}
>
{session.name || session.external_id}
</button>
</td>
<td className="text-xs text-muted" style={{ textAlign: 'right', padding: '10px 0' }}>
{formatTimestamp(session.updated_at)}
</td>
</tr>
);
})}
{sessions.length === 0 && (
<tr>
<td colSpan={3} className="text-base text-muted"
style={{ textAlign: 'center', padding: '40px' }}>
No conversations yet
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center" style={{
borderTop: '1px solid var(--border)',
padding: '10px 24px',
gap: '12px',
flexShrink: 0,
justifyContent: 'flex-end',
}}>
<span className="text-xs text-muted">
Page {page + 1} of {totalPages}
</span>
<button
className="btn-icon"
onClick={() => setPage(p => p - 1)}
disabled={page === 0}
style={{ fontSize: '14px' }}
></button>
<button
className="btn-icon"
onClick={() => setPage(p => p + 1)}
disabled={(page + 1) * PAGE_SIZE >= total}
style={{ fontSize: '14px' }}
></button>
</div>
)}
{/* Bulk delete confirmation dialog */}
{confirmOpen && (
<div onClick={() => setConfirmOpen(false)} style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 100,
}}>
<div onClick={e => e.stopPropagation()} style={{
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
padding: '24px', width: '360px',
display: 'flex', flexDirection: 'column', gap: '16px',
}}>
<h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-primary)' }}>
Delete {selected.size} conversation{selected.size !== 1 ? 's' : ''}?
</h2>
<p className="text-sm text-secondary">
This will permanently remove all selected conversations and their messages. This cannot be undone.
</p>
<div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}>
<button
className="btn-reset text-base text-muted"
onClick={() => setConfirmOpen(false)}
style={{ padding: '8px 14px', borderRadius: 'var(--radius-md)' }}
>Cancel</button>
<button
className="btn-reset text-base"
onClick={handleBulkDelete}
disabled={deleting}
style={{
padding: '8px 16px', borderRadius: 'var(--radius-md)',
background: deleting ? 'var(--bg-elevated)' : '#c0392b',
color: deleting ? 'var(--text-muted)' : 'white',
}}
>{deleting ? 'Deleting...' : 'Delete'}</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,161 @@
import React, { useState, useEffect } from 'react';
import ProjectModal from './ProjectModal';
import { fetchProjects, createProject, updateProject, deleteProject } from '../api/orchestration';
export default function AllProjectsView({ onProjectsChange }) {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState(null); // { mode, project? }
useEffect(() => { load(); }, []);
async function load() {
setLoading(true);
try {
setProjects(await fetchProjects());
} catch (err) {
console.error('[AllProjectsView] Failed to load:', err.message);
} finally {
setLoading(false);
}
}
async function handleSave({ name, description, colour, icon }) {
try {
if (modal.mode === 'create') {
await createProject({ name, description, colour, icon });
} else {
await updateProject(modal.project.id, { name, description, colour, icon });
}
await load();
onProjectsChange?.(); // add this
} catch (err) {
console.error('[AllProjectsView] Save failed:', err.message);
}
}
async function handleDelete(id) {
try {
await deleteProject(id);
await load();
onProjectsChange?.(); // add this
} catch (err) {
console.error('[AllProjectsView] Delete failed:', err.message);
}
}
return (
<div className="flex-col flex-1 overflow-hidden" style={{ background: 'var(--bg-base)' }}>
{/* Header */}
<div className="panel-header" style={{ padding: '0 24px', justifyContent: 'space-between' }}>
<span className="text-base" style={{ fontWeight: 500, color: 'var(--text-secondary)' }}>
All Projects
</span>
<button
className="btn-primary"
onClick={() => setModal({ mode: 'create' })}
style={{ padding: '5px 12px', fontSize: '12px' }}
>
+ New Project
</button>
</div>
{/* Tile grid */}
<div className="flex-1 scroll-y" style={{ padding: '24px' }}>
{loading ? (
<div className="text-base text-muted" style={{ textAlign: 'center', padding: '40px' }}>
Loading...
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '16px',
}}>
{projects.map(project => (
<ProjectTile
key={project.id}
project={project}
onEdit={() => setModal({ mode: 'edit', project })}
onDelete={() => setModal({ mode: 'confirm-delete', project })}
/>
))}
{projects.length === 0 && (
<div className="text-base text-muted" style={{
gridColumn: '1 / -1', textAlign: 'center', padding: '60px 0',
}}>
No projects yet create one to get started
</div>
)}
</div>
)}
</div>
{modal && (
<ProjectModal
project={modal.project}
mode={modal.mode}
onSave={handleSave}
onDelete={handleDelete}
onClose={() => setModal(null)}
/>
)}
</div>
);
}
function ProjectTile({ project, onEdit, onDelete }) {
const [hovered, setHovered] = useState(false);
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
background: 'var(--bg-surface)',
border: `1px solid ${hovered ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-lg)',
padding: '16px',
display: 'flex', flexDirection: 'column', gap: '8px',
transition: 'border-color 0.15s',
position: 'relative',
minHeight: '100px',
}}
>
{/* Colour accent bar */}
<div style={{
position: 'absolute', top: 0, left: 0, right: 0,
height: '3px',
background: project.colour ?? 'var(--accent)',
borderRadius: 'var(--radius-lg) var(--radius-lg) 0 0',
}} />
<span className="text-base truncate" style={{
fontWeight: 500, color: 'var(--text-primary)', marginTop: '4px',
}}>
{project.name}
</span>
{project.description && (
<span className="text-xs text-muted" style={{
display: '-webkit-box', WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>
{project.description}
</span>
)}
{/* Action buttons — appear on hover */}
{hovered && (
<div className="flex" style={{ gap: '4px', marginTop: 'auto', justifyContent: 'flex-end' }}>
<button className="btn-icon" onClick={onEdit}
title="Edit" style={{ fontSize: '12px' }}></button>
<button className="btn-icon" onClick={onDelete}
title="Delete" style={{ fontSize: '12px', color: '#ff6b6b' }}></button>
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useRef } from 'react';
import MessageBubble from './MessageBubble';
export default function ChatWindow({ messages, loadingHistory, streaming, onSendMessage, onCancel, activeSession }) {
export default function ChatWindow({ messages, loadingHistory, streaming, onSendMessage, onCancel, activeSession, onTogglePanel }) {
const bottomRef = useRef(null);
const inputRef = useRef(null);
const [input, setInput] = React.useState('');
@@ -28,10 +28,11 @@ export default function ChatWindow({ messages, loadingHistory, streaming, onSend
<div className="flex-col flex-1 overflow-hidden" style={{ background: 'var(--bg-base)' }}>
{/* Header */}
<div className="panel-header" style={{ padding: '0 20px' }}>
<div className="panel-header" style={{ padding: '0 12px 0 20px', justifyContent: 'space-between' }}>
<span className="text-base text-secondary">
{activeSession ? ( activeSession.name || activeSession.external_id) : 'No session selected'}
{activeSession ? (activeSession.name || activeSession.external_id) : 'No session selected'}
</span>
<button className="btn-icon" onClick={onTogglePanel} title="Session info"></button>
</div>
{/* Message thread */}

View File

@@ -3,13 +3,17 @@ import React from 'react';
export default function InfoPanel({ isOpen, onToggle, activeSession, lastModel, lastTokenCount, selectedModel, onModelChange, models }) {
return (
<div className="flex-col" style={{
width: isOpen ? 'var(--panel-width)' : '56px',
flexShrink: 0,
background: 'var(--bg-surface)',
borderLeft: '1px solid var(--border)',
transition: 'width 0.2s ease',
overflow: 'hidden',
}}>
position: 'fixed',
top: 0,
right: 0,
height: '100vh',
width: 'var(--panel-width)',
background: 'var(--bg-surface)',
borderLeft: '1px solid var(--border)',
transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 0.2s ease',
zIndex: 20,
}}>
{/* Header */}
<div className="panel-header" style={{
@@ -72,13 +76,6 @@ export default function InfoPanel({ isOpen, onToggle, activeSession, lastModel,
</div>
)}
{!isOpen && (
<div className="flex-col items-center" style={{ flex: 1, paddingTop: '16px', gap: '16px' }}>
<IconHint title="Model">M</IconHint>
<IconHint title="Session">S</IconHint>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,143 @@
import React, { useState, useEffect, useRef } from 'react';
const COLOURS = ['#3d3a79', '#2d6a4f', '#7b2d8b', '#c0392b', '#d4800a', '#1a6b8a'];
export default function ProjectModal({ project, mode, onSave, onDelete, onClose }) {
const [name, setName] = useState(project?.name ?? '');
const [description, setDescription] = useState(project?.description ?? '');
const [colour, setColour] = useState(project?.colour ?? COLOURS[0]);
const inputRef = useRef(null);
useEffect(() => {
if (mode !== 'confirm-delete') inputRef.current?.focus();
}, [mode]);
function handleSubmit() {
const trimmed = name.trim();
if (!trimmed) return;
onSave({ name: trimmed, description: description.trim() || null, colour, icon: null });
onClose();
}
function handleKeyDown(e) {
if (e.key === 'Enter' && mode !== 'confirm-delete') handleSubmit();
if (e.key === 'Escape') onClose();
}
return (
<div onClick={onClose} style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 100,
}}>
<div onClick={e => e.stopPropagation()} onKeyDown={handleKeyDown} style={{
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
padding: '24px', width: '380px',
display: 'flex', flexDirection: 'column', gap: '16px',
}}>
{mode === 'confirm-delete' ? (
<>
<h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-primary)' }}>
Delete project?
</h2>
<p className="text-sm text-secondary">
Are you sure you want to delete{' '}
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{project.name}</span>?
Sessions in this project will not be deleted.
</p>
<div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn-reset text-base text-muted"
onClick={onClose}
style={{ padding: '8px 14px', borderRadius: 'var(--radius-md)' }}>
Cancel
</button>
<button className="btn-reset text-base"
onClick={() => { onDelete(project.id); onClose(); }}
style={{ padding: '8px 16px', borderRadius: 'var(--radius-md)', background: '#c0392b', color: 'white' }}>
Delete
</button>
</div>
</>
) : (
<>
<h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-primary)' }}>
{mode === 'create' ? 'New Project' : 'Edit Project'}
</h2>
{/* Name */}
<div className="flex-col" style={{ gap: '6px' }}>
<label className="label-upper">Name</label>
<input
ref={inputRef}
value={name}
onChange={e => setName(e.target.value)}
placeholder="Project name..."
style={{
background: 'var(--bg-elevated)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)', padding: '8px 12px',
color: 'var(--text-primary)', fontSize: '14px', outline: 'none', width: '100%',
}}
/>
</div>
{/* Description */}
<div className="flex-col" style={{ gap: '6px' }}>
<label className="label-upper">Description <span style={{ opacity: 0.5 }}>(optional)</span></label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="What's this project about..."
rows={2}
style={{
background: 'var(--bg-elevated)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)', padding: '8px 12px',
color: 'var(--text-primary)', fontSize: '14px', outline: 'none',
width: '100%', resize: 'none', fontFamily: 'inherit',
}}
/>
</div>
{/* Colour picker */}
<div className="flex-col" style={{ gap: '6px' }}>
<label className="label-upper">Colour</label>
<div className="flex" style={{ gap: '8px' }}>
{COLOURS.map(c => (
<button
key={c}
onClick={() => setColour(c)}
className="btn-reset"
style={{
width: '24px', height: '24px',
borderRadius: '50%',
background: c,
border: colour === c ? '2px solid var(--text-primary)' : '2px solid transparent',
outline: colour === c ? '2px solid var(--accent-hover)' : 'none',
outlineOffset: '2px',
}}
/>
))}
</div>
</div>
<div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn-reset text-base text-muted"
onClick={onClose}
style={{ padding: '8px 14px', borderRadius: 'var(--radius-md)' }}>
Cancel
</button>
<button className="btn-primary"
onClick={handleSubmit}
disabled={!name.trim()}
style={{ padding: '8px 16px' }}>
{mode === 'create' ? 'Create' : 'Save'}
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
export default function SettingsView() {
return (
<div className="flex-col flex-1 overflow-hidden" style={{ background: 'var(--bg-base)' }}>
{/* Header */}
<div className="panel-header" style={{ padding: '0 24px' }}>
<span className="text-base" style={{ fontWeight: 500, color: 'var(--text-secondary)' }}>
Settings
</span>
</div>
<div className="flex-1 scroll-y" style={{ padding: '24px' }}>
<SettingsSection title="Appearance">
<Placeholder />
</SettingsSection>
<SettingsSection title="Memory">
<Placeholder />
</SettingsSection>
<SettingsSection title="Models">
<Placeholder />
</SettingsSection>
<SettingsSection title="About">
<Placeholder />
</SettingsSection>
</div>
</div>
);
}
function SettingsSection({ title, children }) {
return (
<div style={{ marginBottom: '32px' }}>
<p className="label-upper" style={{ marginBottom: '12px', color: 'var(--text-secondary)' }}>
{title}
</p>
<div style={{
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
overflow: 'hidden',
}}>
{children}
</div>
</div>
);
}
function Placeholder() {
return (
<div className="text-sm text-muted" style={{ padding: '20px 16px' }}>
Coming soon
</div>
);
}

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

View File

@@ -0,0 +1,18 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchProjects } from '../api/orchestration';
export function useProjects() {
const [projects, setProjects] = useState([]);
const refreshProjects = useCallback(async () => {
try {
setProjects(await fetchProjects());
} catch (err) {
console.warn('[useProjects] Failed to load projects:', err.message);
}
}, []);
useEffect(() => { refreshProjects(); }, [refreshProjects]);
return { projects, refreshProjects };
}