project view updates

This commit is contained in:
Storme-bit
2026-04-18 22:53:03 -07:00
parent ad5ecb5ff3
commit e69ceb44e7
10 changed files with 274 additions and 121 deletions

View File

@@ -108,7 +108,7 @@ export default function App() {
}, 50); }, 50);
} }
async function handleNewProjectChat() { function handleNewProjectChat(text) {
const newSession = { const newSession = {
external_id: uuidv4(), external_id: uuidv4(),
metadata: null, metadata: null,
@@ -117,7 +117,12 @@ export default function App() {
}; };
setSessions(prev => [newSession, ...prev]); setSessions(prev => [newSession, ...prev]);
selectSession(newSession); selectSession(newSession);
navigate('chat'); setViewHistory(prev => [...prev, view]);
setView('chat');
setLeftOpen(true);
setTimeout(() => {
sendMessage(text, selectedModel, activeProject?.id ?? null);
}, 50);
} }
const canGoBack = view !== 'home'; const canGoBack = view !== 'home';
@@ -172,6 +177,8 @@ export default function App() {
<AllProjectsView <AllProjectsView
onBack={goBack} onBack={goBack}
onProjectsChange={refreshProjects} onProjectsChange={refreshProjects}
onSelectProject={setActiveProject}
onNavigate={navigate}
/> />
)} )}
@@ -190,6 +197,7 @@ export default function App() {
onBack={goBack} onBack={goBack}
onSelectSession={selectSession} onSelectSession={selectSession}
onNewProjectChat={handleNewProjectChat} onNewProjectChat={handleNewProjectChat}
onProjectsChange={refreshProjects} // add
/> />
)} )}

Binary file not shown.

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { fetchSessions, deleteSession } from '../api/orchestration'; import { fetchSessions, deleteSession } from '../api/orchestration';
import { API_DEFAULTS } from '../config/constants'; import { API_DEFAULTS, CLIENT_DEFAULTS } from '../config/constants';
const PAGE_SIZE = API_DEFAULTS.PAGE_SIZE; const PAGE_SIZE = CLIENT_DEFAULTS.PAGE_SIZE;
export default function AllChatsView({ onSelectSession, onBack }) { export default function AllChatsView({ onSelectSession, onBack }) {
const [sessions, setSessions] = useState([]); const [sessions, setSessions] = useState([]);

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import ProjectModal from './ProjectModal'; import ProjectModal from './ProjectModal';
import { fetchProjects, createProject, updateProject, deleteProject } from '../api/orchestration'; import { fetchProjects, createProject, updateProject, deleteProject } from '../api/orchestration';
export default function AllProjectsView({ onProjectsChange, onBack }) { export default function AllProjectsView({ onProjectsChange, onBack, onSelectProject, onNavigate }) {
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modal, setModal] = useState(null); // { mode, project? } const [modal, setModal] = useState(null); // { mode, project? }
@@ -76,12 +76,13 @@ async function handleDelete(id) {
}}> }}>
{projects.map(project => ( {projects.map(project => (
<ProjectTile <ProjectTile
key={project.id} key={project.id}
project={project} project={project}
onEdit={() => setModal({ mode: 'edit', project })} onSelect={() => { onSelectProject(project); onNavigate('project'); }}
onDelete={() => setModal({ mode: 'confirm-delete', project })} onEdit={() => setModal({ mode: 'edit', project })}
/> onDelete={() => setModal({ mode: 'confirm-delete', project })}
))} />
))}
{projects.length === 0 && ( {projects.length === 0 && (
<div className="text-base text-muted" style={{ <div className="text-base text-muted" style={{
@@ -107,11 +108,12 @@ async function handleDelete(id) {
); );
} }
function ProjectTile({ project, onEdit, onDelete }) { function ProjectTile({ project, onSelect, onEdit, onDelete }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
return ( return (
<div <div
onClick={onSelect}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
style={{ style={{
@@ -123,6 +125,7 @@ function ProjectTile({ project, onEdit, onDelete }) {
transition: 'border-color 0.15s', transition: 'border-color 0.15s',
position: 'relative', position: 'relative',
minHeight: '100px', minHeight: '100px',
cursor: 'pointer',
}} }}
> >
{/* Colour accent bar */} {/* Colour accent bar */}
@@ -151,9 +154,9 @@ function ProjectTile({ project, onEdit, onDelete }) {
{/* Action buttons — appear on hover */} {/* Action buttons — appear on hover */}
{hovered && ( {hovered && (
<div className="flex" style={{ gap: '4px', marginTop: 'auto', justifyContent: 'flex-end' }}> <div className="flex" style={{ gap: '4px', marginTop: 'auto', justifyContent: 'flex-end' }}>
<button className="btn-icon" onClick={onEdit} <button className="btn-icon" onClick={e => { e.stopPropagation(); onEdit(); }}
title="Edit" style={{ fontSize: '12px' }}></button> title="Edit" style={{ fontSize: '12px' }}></button>
<button className="btn-icon" onClick={onDelete} <button className="btn-icon" onClick={e => { e.stopPropagation(); onDelete(); }}
title="Delete" style={{ fontSize: '12px', color: '#ff6b6b' }}></button> title="Delete" style={{ fontSize: '12px', color: '#ff6b6b' }}></button>
</div> </div>
)} )}

View File

@@ -4,7 +4,7 @@ import ReactMarkdown from 'react-markdown';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
export default function MemoryView({ onNavigate }) { export default function MemoryView({ onNavigate, onBack }) {
const [episodes, setEpisodes] = useState([]); const [episodes, setEpisodes] = useState([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);

View File

@@ -6,7 +6,6 @@ export default function ProjectModal({ project, mode, onSave, onDelete, onClose
const [name, setName] = useState(project?.name ?? ''); const [name, setName] = useState(project?.name ?? '');
const [description, setDescription] = useState(project?.description ?? ''); const [description, setDescription] = useState(project?.description ?? '');
const [colour, setColour] = useState(project?.colour ?? COLOURS[0]); const [colour, setColour] = useState(project?.colour ?? COLOURS[0]);
const [isolated, setIsolated] = useState(project?.isolated === 1);
const inputRef = useRef(null); const inputRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -16,7 +15,7 @@ export default function ProjectModal({ project, mode, onSave, onDelete, onClose
function handleSubmit() { function handleSubmit() {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) return; if (!trimmed) return;
onSave({ name: trimmed, description: description.trim() || null, colour, icon: null, isolated }); onSave({ name: trimmed, description: description.trim() || null, colour, icon: null, isolated: 1 });
onClose(); onClose();
} }
@@ -123,33 +122,6 @@ export default function ProjectModal({ project, mode, onSave, onDelete, onClose
</div> </div>
</div> </div>
{/* Project-Only toggle */}
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
<div className="flex-col" style={{ gap: '2px' }}>
<label className="label-upper">Project Only</label>
<span className="text-xs text-muted">Limit context to this project only</span>
</div>
<button
type="button"
onClick={() => setIsolated(i => !i)}
style={{
width: '40px', height: '22px', borderRadius: '11px', flexShrink: 0,
background: isolated ? 'var(--accent-hover)' : 'var(--bg-elevated)',
border: '1px solid var(--border)',
position: 'relative', cursor: 'pointer', transition: 'background 0.2s',
}}
>
<span style={{
position: 'absolute', top: '3px',
left: isolated ? '20px' : '3px',
width: '14px', height: '14px',
borderRadius: '50%',
background: isolated ? 'white' : 'var(--text-muted)',
transition: 'left 0.2s',
}} />
</button>
</div>
<div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}> <div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn-reset text-base text-muted" <button className="btn-reset text-base text-muted"
onClick={onClose} onClick={onClose}

View File

@@ -1,10 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { fetchSessions, updateSession } from '../api/orchestration'; import { fetchSessions, updateProject, deleteProject } from '../api/orchestration';
import { v4 as uuidv4 } from 'uuid'; import ProjectModal from './ProjectModal';
export default function ProjectView({ project, onNavigate, onSelectSession, onNewProjectChat }) { export default function ProjectView({ project, onNavigate, onBack, onSelectSession, onNewProjectChat, onProjectsChange }) {
const [sessions, setSessions] = useState([]); const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [input, setInput] = useState('');
const [menuOpen, setMenuOpen] = useState(false);
const [modal, setModal] = useState(null); // { mode: 'edit' | 'confirm-delete' }
useEffect(() => { load(); }, [project.id]); useEffect(() => { load(); }, [project.id]);
@@ -19,6 +22,40 @@ export default function ProjectView({ project, onNavigate, onSelectSession, onNe
} }
} }
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 }) {
try {
await updateProject(project.id, { name, description, colour, icon, isolated });
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) { function formatTimestamp(ts) {
if (!ts) return '—'; if (!ts) return '—';
const date = new Date(ts * 1000); const date = new Date(ts * 1000);
@@ -38,52 +75,63 @@ export default function ProjectView({ project, onNavigate, onSelectSession, onNe
<div className="flex-col flex-1 overflow-hidden" style={{ background: 'var(--bg-base)' }}> <div className="flex-col flex-1 overflow-hidden" style={{ background: 'var(--bg-base)' }}>
{/* Colour accent bar */} {/* Colour accent bar */}
<div style={{ <div style={{ height: '3px', flexShrink: 0, background: project.colour ?? 'var(--accent)' }} />
height: '3px', flexShrink: 0,
background: project.colour ?? 'var(--accent)',
}} />
{/* Header */} {/* Header */}
<div className="panel-header" style={{ padding: '0 24px', justifyContent: 'space-between' }}> <div className="panel-header" style={{ padding: '0 24px', justifyContent: 'space-between' }}>
<button <button
className="btn-reset text-xs text-muted" className="btn-reset text-xs text-muted"
onClick={onBack} title="Back" onClick={onBack}
style={{ display: 'flex', alignItems: 'center', gap: '4px' }} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
> >
All Projects All Projects
</button> </button>
<button
className="btn-primary" {/* ⋮ menu */}
onClick={onNewProjectChat} <div style={{ position: 'relative' }}>
style={{ padding: '5px 12px', fontSize: '12px' }} <button
> className="btn-icon"
+ New Chat onClick={() => setMenuOpen(o => !o)}
</button> title="Project options"
style={{ fontSize: '18px', letterSpacing: '1px' }}
></button>
{menuOpen && (
<>
{/* Click-away backdrop */}
<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> </div>
{/* Content */} {/* Content */}
<div className="flex-1 scroll-y" style={{ padding: '32px 24px' }}> <div className="flex-1 scroll-y" style={{ padding: '32px 24px' }}>
{/* Project title + meta */} {/* Project title + description */}
<div style={{ marginBottom: '32px' }}> <div style={{ marginBottom: '32px' }}>
<div className="flex items-center" style={{ gap: '10px', marginBottom: '8px' }}> <h1 style={{ fontSize: '22px', fontWeight: 600, color: 'var(--text-primary)', marginBottom: '8px' }}>
<h1 style={{ fontSize: '22px', fontWeight: 600, color: 'var(--text-primary)' }}> {project.name}
{project.name} </h1>
</h1>
{project.isolated === 1 && (
<span style={{
fontSize: '11px', fontWeight: 500,
padding: '2px 8px', borderRadius: '99px',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
color: 'var(--text-muted)',
}}>
Project Only
</span>
)}
</div>
{project.description && ( {project.description && (
<p className="text-sm" style={{ color: 'var(--text-secondary)', maxWidth: '560px', lineHeight: 1.6 }}> <p className="text-sm" style={{ color: 'var(--text-secondary)', maxWidth: '560px', lineHeight: 1.6 }}>
{project.description} {project.description}
@@ -91,56 +139,163 @@ export default function ProjectView({ project, onNavigate, onSelectSession, onNe
)} )}
</div> </div>
{/* Sessions list */} {/* Conversations */}
<div> <div>
<p className="label-upper" style={{ marginBottom: '12px' }}>Conversations</p> <p className="label-upper" style={{ marginBottom: '12px' }}>Conversations</p>
{loading ? ( {loading ? (
<div className="text-sm text-muted">Loading...</div> <div className="text-sm text-muted">Loading...</div>
) : sessions.length === 0 ? ( ) : sessions.length === 0 ? (
<div style={{ /* Empty state */
padding: '32px', textAlign: 'center', <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '16px', padding: '32px 0' }}>
border: '1px dashed var(--border)', borderRadius: 'var(--radius-lg)', <p className="text-sm text-muted">No conversations yet start one below</p>
color: 'var(--text-muted)', <div style={{ width: '100%', maxWidth: '520px' }}>
}}> <div style={{
<p className="text-sm">No conversations in this project yet</p> background: 'var(--bg-elevated)',
<p className="text-xs" style={{ marginTop: '6px' }}> border: '1px solid var(--border)',
Click "+ New Chat" to start one borderRadius: 'var(--radius-lg)',
</p> padding: '12px 14px',
}}>
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Start a conversation in ${project.name}`}
rows={1}
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={handleSend}
disabled={!input.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>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column' }}> /* Sessions list + new conversation input */
{sessions.map((session, i) => ( <>
<button <div style={{ display: 'flex', flexDirection: 'column', marginBottom: '24px' }}>
key={session.external_id} {sessions.map((session, i) => (
className="btn-reset" <button
onClick={() => { onSelectSession(session); onNavigate('chat'); }} key={session.external_id}
style={{ className="btn-reset"
padding: '12px 16px', onClick={() => { onSelectSession(session); onNavigate('chat'); }}
display: 'flex', alignItems: 'center', justifyContent: 'space-between', style={{
borderBottom: i < sessions.length - 1 ? '1px solid var(--border)' : 'none', padding: '12px 16px',
borderRadius: i === 0 display: 'flex', alignItems: 'center', justifyContent: 'space-between',
? 'var(--radius-md) var(--radius-md) 0 0' borderBottom: i < sessions.length - 1 ? '1px solid var(--border)' : 'none',
: i === sessions.length - 1 borderRadius: i === 0
? '0 0 var(--radius-md) var(--radius-md)' ? 'var(--radius-md) var(--radius-md) 0 0'
: '0', : i === sessions.length - 1
background: 'var(--bg-surface)', ? '0 0 var(--radius-md) var(--radius-md)'
textAlign: 'left', : '0',
}} background: 'var(--bg-surface)',
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-elevated)'} textAlign: 'left',
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-surface)'} }}
> onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-elevated)'}
<span className="text-base" style={{ color: 'var(--text-primary)' }}> onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-surface)'}
{session.name || session.external_id} >
</span> <span className="text-base" style={{ color: 'var(--text-primary)' }}>
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: '16px' }}> {session.name || session.external_id}
{formatTimestamp(session.updated_at)} </span>
</span> <span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: '16px' }}>
</button> {formatTimestamp(session.updated_at)}
))} </span>
</div> </button>
))}
</div>
{/* New conversation input */}
<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={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`New conversation in ${project.name}`}
rows={1}
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={handleSend}
disabled={!input.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>
</>
)} )}
</div> </div>
</div> </div>
{/* Modal */}
{modal && (
<ProjectModal
project={project}
mode={modal.mode}
onSave={handleSave}
onDelete={handleDelete}
onClose={() => setModal(null)}
/>
)}
</div> </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>
);
} }

View File

@@ -36,6 +36,7 @@ export function useSession() {
const selectSession = useCallback(async (session) => { const selectSession = useCallback(async (session) => {
setActiveSession(session); setActiveSession(session);
setMessages([]); setMessages([]);
if (!session || session.isNew) return;
setLoadingHistory(true); setLoadingHistory(true);
try { try {

View File

@@ -30,6 +30,9 @@ function getDB() {
db.exec(`ALTER TABLE projects ADD COLUMN isolated INTEGER NOT NULL DEFAULT 0`); db.exec(`ALTER TABLE projects ADD COLUMN isolated INTEGER NOT NULL DEFAULT 0`);
} catch {} } catch {}
try {
db.exec(`ALTER TABLE projects ADD COLUMN notes TEXT`); // ← add this
} catch {}
// Sync FTS index with any existing episodes data // Sync FTS index with any existing episodes data
db.exec(`INSERT OR REPLACE INTO episodes_fts(rowid, user_message, ai_response) db.exec(`INSERT OR REPLACE INTO episodes_fts(rowid, user_message, ai_response)

View File

@@ -20,12 +20,23 @@ function getProject(id) {
return parseRow(db.prepare(`SELECT * FROM projects WHERE id = ?`).get(id)); return parseRow(db.prepare(`SELECT * FROM projects WHERE id = ?`).get(id));
} }
function updateProject(id, { name, description, colour, icon, isolated }) { function updateProject(id, fields = {}) {
const db = getDB(); const db = getDB();
db.prepare(` const allowed = ['name', 'description', 'colour', 'icon', 'isolated', 'notes'];
UPDATE projects SET name = ?, description = ?, colour = ?, icon = ?, isolated = ? const updates = [];
WHERE id = ? const values = [];
`).run(name, description ?? null, colour ?? null, icon ?? null, isolated ?? 0, id);
for (const key of allowed) {
if (fields[key] !== undefined) {
updates.push(`${key} = ?`);
values.push(fields[key] ?? null);
}
}
if (updates.length === 0) return getProject(id);
values.push(id);
db.prepare(`UPDATE projects SET ${updates.join(', ')} WHERE id = ?`).run(...values);
return getProject(id); return getProject(id);
} }