diff --git a/packages/chat-client/src/App.jsx b/packages/chat-client/src/App.jsx index dd90474..3824fdd 100644 --- a/packages/chat-client/src/App.jsx +++ b/packages/chat-client/src/App.jsx @@ -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', }}> - setView('all-projects')} isOpen={leftOpen} onToggle={() => setLeftOpen(o => !o)} onSessionsChange={handleSessionsChange} + onNavigate={setView} + projects={projects} + onProjectsChange={refreshProjects} /> + + {view === 'chat' && ( + messages={messages} + loadingHistory={loadingHistory} + streaming={streaming} + activeSession={activeSession} + onSendMessage={handleSendMessage} + onCancel={cancelStream} + onTogglePanel={() => setRightOpen(o => !o)} + /> + )} + + {view === 'all-chats' && ( + {selectSession(session); setView('chat');}} + /> + )} + + {view === 'all-projects' && ( + + )} + + {view === 'settings' && } + { + 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 ( +
+ + {/* Header */} +
+ + All Chats + + {selected.size > 0 && ( + + )} +
+ + {/* Table */} +
+ {loading ? ( +
+ Loading... +
+ ) : ( + + + + + + + + + + {sessions.map(session => { + const isSelected = selected.has(session.external_id); + return ( + { if (!isSelected) e.currentTarget.style.background = 'var(--bg-surface)'; }} + onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent'; }} + > + + + + + ); + })} + + {sessions.length === 0 && ( + + + + )} + +
+ {/* Select all checkbox */} + + NameLast Active
+ toggleSelect(session.external_id)} + style={{ cursor: 'pointer', accentColor: 'var(--accent-hover)' }} + /> + + + + {formatTimestamp(session.updated_at)} +
+ No conversations yet +
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page + 1} of {totalPages} + + + +
+ )} + + {/* Bulk delete confirmation dialog */} + {confirmOpen && ( +
setConfirmOpen(false)} style={{ + position: 'fixed', inset: 0, + background: 'rgba(0,0,0,0.5)', + display: 'flex', alignItems: 'center', justifyContent: 'center', + zIndex: 100, + }}> +
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', + }}> +

+ Delete {selected.size} conversation{selected.size !== 1 ? 's' : ''}? +

+

+ This will permanently remove all selected conversations and their messages. This cannot be undone. +

+
+ + +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/packages/chat-client/src/components/AllProjectsView.jsx b/packages/chat-client/src/components/AllProjectsView.jsx new file mode 100644 index 0000000..d676935 --- /dev/null +++ b/packages/chat-client/src/components/AllProjectsView.jsx @@ -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 ( +
+ + {/* Header */} +
+ + All Projects + + +
+ + {/* Tile grid */} +
+ {loading ? ( +
+ Loading... +
+ ) : ( +
+ {projects.map(project => ( + setModal({ mode: 'edit', project })} + onDelete={() => setModal({ mode: 'confirm-delete', project })} + /> + ))} + + {projects.length === 0 && ( +
+ No projects yet — create one to get started +
+ )} +
+ )} +
+ + {modal && ( + setModal(null)} + /> + )} +
+ ); +} + +function ProjectTile({ project, onEdit, onDelete }) { + const [hovered, setHovered] = useState(false); + + return ( +
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 */} +
+ + + {project.name} + + + {project.description && ( + + {project.description} + + )} + + {/* Action buttons — appear on hover */} + {hovered && ( +
+ + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/packages/chat-client/src/components/ChatWindow.jsx b/packages/chat-client/src/components/ChatWindow.jsx index 550280c..2acf900 100644 --- a/packages/chat-client/src/components/ChatWindow.jsx +++ b/packages/chat-client/src/components/ChatWindow.jsx @@ -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
{/* Header */} -
+
- {activeSession ? ( activeSession.name || activeSession.external_id) : 'No session selected'} + {activeSession ? (activeSession.name || activeSession.external_id) : 'No session selected'} +
{/* Message thread */} diff --git a/packages/chat-client/src/components/InfoPanel.jsx b/packages/chat-client/src/components/InfoPanel.jsx index 0c0b745..33d813e 100644 --- a/packages/chat-client/src/components/InfoPanel.jsx +++ b/packages/chat-client/src/components/InfoPanel.jsx @@ -3,13 +3,17 @@ import React from 'react'; export default function InfoPanel({ isOpen, onToggle, activeSession, lastModel, lastTokenCount, selectedModel, onModelChange, models }) { return (
+ 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 */}
)} - - {!isOpen && ( -
- M - S -
- )}
); } diff --git a/packages/chat-client/src/components/ProjectModal.jsx b/packages/chat-client/src/components/ProjectModal.jsx new file mode 100644 index 0000000..7af1959 --- /dev/null +++ b/packages/chat-client/src/components/ProjectModal.jsx @@ -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 ( +
+
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' ? ( + <> +

+ Delete project? +

+

+ Are you sure you want to delete{' '} + {project.name}? + Sessions in this project will not be deleted. +

+
+ + +
+ + ) : ( + <> +

+ {mode === 'create' ? 'New Project' : 'Edit Project'} +

+ + {/* Name */} +
+ + 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%', + }} + /> +
+ + {/* Description */} +
+ +