440 lines
16 KiB
JavaScript
440 lines
16 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { fetchSessions, updateProject, deleteProject, generateProjectSummary, fetchProjectOverviewSummary } from '../api/orchestration';
|
|
import ProjectModal from './ProjectModal';
|
|
|
|
export default function ProjectView({ project, onNavigate, onBack, onSelectSession, onNewProjectChat, onProjectsChange }) {
|
|
const [sessions, setSessions] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [input, setInput] = useState('');
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [modal, setModal] = useState(null);
|
|
const [overview, setOverview] = useState(null);
|
|
const [overviewLoading, setOverviewLoading] = useState(true);
|
|
const [generating, setGenerating] = useState(false);
|
|
const [generateError, setGenerateError] = useState(null);
|
|
|
|
useEffect(() => { load(); }, [project.id]);
|
|
|
|
useEffect(() => {
|
|
async function loadOverview() {
|
|
setOverviewLoading(true);
|
|
try {
|
|
setOverview(await fetchProjectOverviewSummary(project.id));
|
|
} catch (err) {
|
|
console.error('[ProjectView] Failed to load overview:', err.message);
|
|
} finally {
|
|
setOverviewLoading(false);
|
|
}
|
|
}
|
|
loadOverview();
|
|
}, [project.id]);
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
try {
|
|
setSessions(await fetchSessions(50, 0, project.id));
|
|
} catch (err) {
|
|
console.error('[ProjectView] Failed to load sessions:', err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleSend() {
|
|
const text = input.trim();
|
|
if (!text) return;
|
|
setInput('');
|
|
onNewProjectChat(text);
|
|
}
|
|
|
|
function handleKeyDown(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
}
|
|
|
|
async function handleSave({ name, description, colour, icon, isolated, system_prompt }) {
|
|
try {
|
|
await updateProject(project.id, { name, description, colour, icon, isolated, system_prompt });
|
|
onProjectsChange?.();
|
|
setModal(null);
|
|
} catch (err) {
|
|
console.error('[ProjectView] Update failed:', err.message);
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
try {
|
|
await deleteProject(project.id);
|
|
onProjectsChange?.();
|
|
onBack();
|
|
} catch (err) {
|
|
console.error('[ProjectView] Delete failed:', err.message);
|
|
}
|
|
}
|
|
|
|
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';
|
|
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
|
}
|
|
|
|
async function handleGenerateSummary() {
|
|
setGenerating(true);
|
|
setGenerateError(null);
|
|
try {
|
|
setOverview(await generateProjectSummary(project.id));
|
|
} catch (err) {
|
|
// 422 means no session summaries exist yet — surface a friendly message
|
|
setGenerateError(
|
|
err.message.includes('422')
|
|
? 'No conversations found in this project yet.'
|
|
: 'Failed to generate summary. Please try again.'
|
|
);
|
|
} finally {
|
|
setGenerating(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex-col flex-1 overflow-hidden" style={{ background: 'var(--bg-base)' }}>
|
|
|
|
{/* Colour accent bar */}
|
|
<div style={{ height: '3px', flexShrink: 0, background: project.colour ?? 'var(--accent)' }} />
|
|
|
|
{/* Header */}
|
|
<div className="panel-header" style={{ padding: '0 24px', justifyContent: 'space-between' }}>
|
|
<button
|
|
className="btn-reset text-xs text-muted"
|
|
onClick={onBack}
|
|
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
|
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
|
|
>
|
|
← All Projects
|
|
</button>
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
<button
|
|
className="btn-icon"
|
|
onClick={() => setMenuOpen(o => !o)}
|
|
title="Project options"
|
|
style={{ fontSize: '18px', letterSpacing: '1px' }}
|
|
>⋮</button>
|
|
|
|
{menuOpen && (
|
|
<>
|
|
<div style={{ position: 'fixed', inset: 0, zIndex: 40 }} onClick={() => setMenuOpen(false)} />
|
|
<div style={{
|
|
position: 'absolute', top: '100%', right: 0,
|
|
background: 'var(--bg-elevated)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-md)',
|
|
padding: '4px', zIndex: 50, minWidth: '150px',
|
|
}}>
|
|
<MenuButton onClick={() => { setMenuOpen(false); setModal({ mode: 'edit' }); }}>
|
|
✎ Edit details
|
|
</MenuButton>
|
|
<MenuButton danger onClick={() => { setMenuOpen(false); setModal({ mode: 'confirm-delete' }); }}>
|
|
✕ Delete project
|
|
</MenuButton>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scrollable content */}
|
|
<div className="flex-1 scroll-y" style={{ padding: '32px 24px' }}>
|
|
|
|
{/* Project title + description */}
|
|
<div style={{ marginBottom: '32px' }}>
|
|
<h1 style={{ fontSize: '22px', fontWeight: 600, color: 'var(--text-primary)', marginBottom: '8px' }}>
|
|
{project.name}
|
|
</h1>
|
|
{project.description && (
|
|
<p className="text-sm" style={{ color: 'var(--text-secondary)', maxWidth: '560px', lineHeight: 1.6 }}>
|
|
{project.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Conversations ── */}
|
|
<div style={{ marginBottom: '40px' }}>
|
|
<p className="label-upper" style={{ marginBottom: '12px' }}>Conversations</p>
|
|
|
|
{loading ? (
|
|
<div className="text-sm text-muted">Loading...</div>
|
|
|
|
) : sessions.length === 0 ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '16px', padding: '32px 0' }}>
|
|
<p className="text-sm text-muted">No conversations yet — start one below</p>
|
|
<ChatInput
|
|
value={input}
|
|
onChange={setInput}
|
|
onSend={handleSend}
|
|
placeholder={`Start a conversation in ${project.name}…`}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
) : (
|
|
<>
|
|
<div style={{ display: 'flex', flexDirection: 'column', marginBottom: '16px' }}>
|
|
{sessions.map((session, i) => (
|
|
<button
|
|
key={session.external_id}
|
|
className="btn-reset"
|
|
onClick={() => { onSelectSession(session); onNavigate('chat'); }}
|
|
style={{
|
|
padding: '12px 16px',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
borderBottom: i < sessions.length - 1 ? '1px solid var(--border)' : 'none',
|
|
borderRadius: i === 0
|
|
? 'var(--radius-md) var(--radius-md) 0 0'
|
|
: i === sessions.length - 1
|
|
? '0 0 var(--radius-md) var(--radius-md)'
|
|
: '0',
|
|
background: 'var(--bg-surface)',
|
|
textAlign: 'left',
|
|
}}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-elevated)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-surface)'}
|
|
>
|
|
<span className="text-base" style={{ color: 'var(--text-primary)' }}>
|
|
{session.name || session.external_id}
|
|
</span>
|
|
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: '16px' }}>
|
|
{formatTimestamp(session.updated_at)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<ChatInput
|
|
value={input}
|
|
onChange={setInput}
|
|
onSend={handleSend}
|
|
placeholder={`New conversation in ${project.name}…`}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Project Memory ── */}
|
|
<div style={{ marginBottom: '40px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
|
<p className="label-upper">Project Memory</p>
|
|
<button
|
|
className="btn-primary"
|
|
style={{ padding: '5px 12px', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}
|
|
onClick={handleGenerateSummary}
|
|
disabled={generating}
|
|
>
|
|
{generating
|
|
? <><span className="spinner" />Generating…</>
|
|
: overview ? 'Regenerate' : 'Generate Summary'
|
|
}
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{
|
|
background: 'var(--bg-surface)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)',
|
|
padding: '20px',
|
|
}}>
|
|
{overviewLoading ? (
|
|
<p className="text-sm text-muted">Loading…</p>
|
|
|
|
) : generateError ? (
|
|
<p className="text-sm" style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
|
{generateError}
|
|
</p>
|
|
|
|
) : overview ? (
|
|
<>
|
|
<p className="text-sm" style={{ color: 'var(--text-secondary)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
|
|
{overview.content}
|
|
</p>
|
|
<p className="text-xs text-muted" style={{ marginTop: '12px' }}>
|
|
Last generated {formatTimestamp(overview.created_at)}
|
|
</p>
|
|
</>
|
|
|
|
) : (
|
|
// No overview exists yet — explain what this section is for
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
<span style={{ fontSize: '20px', opacity: 0.4 }}>◈</span>
|
|
<span className="text-sm" style={{ fontWeight: 500, color: 'var(--text-primary)' }}>
|
|
No project summary yet
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-muted" style={{ lineHeight: 1.6, maxWidth: '520px' }}>
|
|
Generate a summary to create a concise overview of this project's goals,
|
|
progress, and key decisions — built from your session summaries.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Notes ── */}
|
|
<NotesSection projectId={project.id} initialNotes={project.notes ?? ''} />
|
|
|
|
</div>
|
|
|
|
{/* Modal */}
|
|
{modal && (
|
|
<ProjectModal
|
|
project={project}
|
|
mode={modal.mode}
|
|
onSave={handleSave}
|
|
onDelete={handleDelete}
|
|
onClose={() => setModal(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Sub-components ─────────────────────────────────────────
|
|
|
|
function ChatInput({ value, onChange, onSend, placeholder, autoFocus }) {
|
|
function handleKeyDown(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
onSend();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ width: '100%', maxWidth: '520px' }}>
|
|
<div style={{
|
|
background: 'var(--bg-elevated)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)',
|
|
padding: '12px 14px',
|
|
}}>
|
|
<textarea
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
rows={1}
|
|
autoFocus={autoFocus}
|
|
style={{
|
|
width: '100%', background: 'transparent',
|
|
border: 'none', outline: 'none',
|
|
color: 'var(--text-primary)', fontSize: '14px',
|
|
lineHeight: '1.6', resize: 'none', fontFamily: 'inherit',
|
|
maxHeight: '120px', overflowY: 'auto',
|
|
}}
|
|
onInput={e => {
|
|
e.target.style.height = 'auto';
|
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
}}
|
|
/>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
|
<button
|
|
onClick={onSend}
|
|
disabled={!value.trim()}
|
|
className="btn-primary"
|
|
style={{ width: '32px', height: '32px', fontSize: '16px', border: '1px solid var(--border)' }}
|
|
>↑</button>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-muted" style={{ textAlign: 'center', marginTop: '8px' }}>
|
|
Enter to send · Shift+Enter for new line
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NotesSection({ projectId, initialNotes }) {
|
|
const [notes, setNotes] = useState(initialNotes);
|
|
const [savedNotes, setSavedNotes] = useState(initialNotes);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const isDirty = notes !== savedNotes;
|
|
|
|
async function handleSave() {
|
|
setSaving(true);
|
|
try {
|
|
await updateProject(projectId, { notes });
|
|
setSavedNotes(notes);
|
|
} catch (err) {
|
|
console.error('[NotesSection] Save failed:', err.message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ marginBottom: '40px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
|
<p className="label-upper">Project Notes</p>
|
|
{isDirty && (
|
|
<button
|
|
className="btn-primary"
|
|
style={{ padding: '5px 12px', fontSize: '12px' }}
|
|
disabled={saving}
|
|
onClick={handleSave}
|
|
>
|
|
{saving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<textarea
|
|
value={notes}
|
|
onChange={e => setNotes(e.target.value)}
|
|
placeholder="Add notes about this project — references, goals, context, anything useful…"
|
|
rows={6}
|
|
style={{
|
|
width: '100%',
|
|
background: 'var(--bg-surface)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)',
|
|
padding: '14px 16px',
|
|
color: 'var(--text-primary)',
|
|
fontSize: '13px', lineHeight: '1.6',
|
|
resize: 'vertical', fontFamily: 'inherit',
|
|
outline: 'none', boxSizing: 'border-box',
|
|
}}
|
|
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
|
onBlur={e => e.target.style.borderColor = 'var(--border)'}
|
|
/>
|
|
{!isDirty && notes && (
|
|
<p className="text-xs text-muted" style={{ marginTop: '6px' }}>Saved</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MenuButton({ children, onClick, danger }) {
|
|
return (
|
|
<button
|
|
className="btn-reset text-sm"
|
|
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>
|
|
);
|
|
} |