chat client UI restructure + added all projects view and settings view(placeholder)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
253
packages/chat-client/src/components/AllChatsView.jsx
Normal file
253
packages/chat-client/src/components/AllChatsView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
packages/chat-client/src/components/AllProjectsView.jsx
Normal file
161
packages/chat-client/src/components/AllProjectsView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
143
packages/chat-client/src/components/ProjectModal.jsx
Normal file
143
packages/chat-client/src/components/ProjectModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
packages/chat-client/src/components/SettingsView.jsx
Normal file
56
packages/chat-client/src/components/SettingsView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
18
packages/chat-client/src/hooks/useProjects.js
Normal file
18
packages/chat-client/src/hooks/useProjects.js
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user