chat sessions in project view
This commit is contained in:
@@ -7,6 +7,7 @@ import Sidebar from './components/Sidebar';
|
|||||||
import AllChatsView from './components/AllChatsView';
|
import AllChatsView from './components/AllChatsView';
|
||||||
import AllProjectsView from './components/AllProjectsView';
|
import AllProjectsView from './components/AllProjectsView';
|
||||||
import SettingsView from './components/SettingsView';
|
import SettingsView from './components/SettingsView';
|
||||||
|
import ProjectView from './components/ProjectView';
|
||||||
|
|
||||||
/**** useHooks **** */
|
/**** useHooks **** */
|
||||||
import { useSession } from './hooks/useSession';
|
import { useSession } from './hooks/useSession';
|
||||||
@@ -19,6 +20,7 @@ export default function App() {
|
|||||||
const [rightOpen, setRightOpen] = useState(false);
|
const [rightOpen, setRightOpen] = useState(false);
|
||||||
const { models, selectedModel, setSelectedModel } = useModels();
|
const { models, selectedModel, setSelectedModel } = useModels();
|
||||||
const [view, setView] = useState('chat')
|
const [view, setView] = useState('chat')
|
||||||
|
const [activeProject, setActiveProject] = useState(null);
|
||||||
const {projects, refreshProjects} = useProjects();
|
const {projects, refreshProjects} = useProjects();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -42,7 +44,7 @@ export default function App() {
|
|||||||
} = useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions });
|
} = useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions });
|
||||||
|
|
||||||
function handleSendMessage(text) {
|
function handleSendMessage(text) {
|
||||||
sendMessage(text, selectedModel);
|
sendMessage(text, selectedModel, activeSession?.project_id ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSessionsChange(deletedSession){
|
function handleSessionsChange(deletedSession){
|
||||||
@@ -52,6 +54,20 @@ export default function App() {
|
|||||||
refreshSessions();
|
refreshSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleNewProjectChat() {
|
||||||
|
const newSession = {
|
||||||
|
external_id: uuidv4(),
|
||||||
|
metadata: null,
|
||||||
|
isNew: true,
|
||||||
|
project_id: activeProject?.id ?? null,
|
||||||
|
};
|
||||||
|
// Optimistically set active session then navigate
|
||||||
|
setSessions(prev => [newSession, ...prev]);
|
||||||
|
selectSession(newSession);
|
||||||
|
setView('chat');
|
||||||
|
// After first message saves, project assignment will be written via updateSession
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -70,6 +86,7 @@ export default function App() {
|
|||||||
onNavigate={setView}
|
onNavigate={setView}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
onProjectsChange={refreshProjects}
|
onProjectsChange={refreshProjects}
|
||||||
|
onSelectProject={setActiveProject}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +114,15 @@ export default function App() {
|
|||||||
|
|
||||||
{view === 'settings' && <SettingsView />}
|
{view === 'settings' && <SettingsView />}
|
||||||
|
|
||||||
|
{view === 'project' && activeProject && (
|
||||||
|
<ProjectView
|
||||||
|
project={activeProject}
|
||||||
|
onNavigate={setView}
|
||||||
|
onSelectSession={selectSession}
|
||||||
|
onNewProjectChat={handleNewProjectChat}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
<InfoPanel
|
<InfoPanel
|
||||||
isOpen={rightOpen}
|
isOpen={rightOpen}
|
||||||
|
|||||||
@@ -186,21 +186,21 @@ export async function fetchProjects() {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProject({ name, description, colour, icon }) {
|
export async function createProject({ name, description, colour, icon, projectOnly }) {
|
||||||
const res = await fetch(`${BASE_URL}/projects`, {
|
const res = await fetch(`${BASE_URL}/projects`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, description, colour, icon }),
|
body: JSON.stringify({ name, description, colour, icon, projectOnly: projectOnly ? 1 : 0 }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Failed to create project: ${res.status}`);
|
if (!res.ok) throw new Error(`Failed to create project: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProject(id, { name, description, colour, icon }) {
|
export async function updateProject(id, { name, description, colour, icon, projectOnly }) {
|
||||||
const res = await fetch(`${BASE_URL}/projects/${id}`, {
|
const res = await fetch(`${BASE_URL}/projects/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, description, colour, icon }),
|
body: JSON.stringify({ name, description, colour, icon, projectOnly: projectOnly ? 1 : 0}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Failed to update project: ${res.status}`);
|
if (!res.ok) throw new Error(`Failed to update project: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 [projectOnly, setProjectOnly] = useState(project?.projectOnly === 1 ?? false);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -15,7 +16,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 });
|
onSave({ name: trimmed, description: description.trim() || null, colour, icon: null, projectOnly });
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +123,33 @@ 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={() => setProjectOnly(i => !i)}
|
||||||
|
style={{
|
||||||
|
width: '40px', height: '22px', borderRadius: '11px', flexShrink: 0,
|
||||||
|
background: projectOnly ? '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: projectOnly ? '20px' : '3px',
|
||||||
|
width: '14px', height: '14px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: projectOnly ? '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}
|
||||||
|
|||||||
146
packages/chat-client/src/components/ProjectView.jsx
Normal file
146
packages/chat-client/src/components/ProjectView.jsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { fetchSessions, updateSession } from '../api/orchestration';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export default function ProjectView({ project, onNavigate, onSelectSession, onNewProjectChat }) {
|
||||||
|
const [sessions, setSessions] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [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 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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={() => onNavigate('all-projects')}
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={onNewProjectChat}
|
||||||
|
style={{ padding: '5px 12px', fontSize: '12px' }}
|
||||||
|
>
|
||||||
|
+ New Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 scroll-y" style={{ padding: '32px 24px' }}>
|
||||||
|
|
||||||
|
{/* Project title + meta */}
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<div className="flex items-center" style={{ gap: '10px', marginBottom: '8px' }}>
|
||||||
|
<h1 style={{ fontSize: '22px', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{project.name}
|
||||||
|
</h1>
|
||||||
|
{project.projectOnly === 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 && (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)', maxWidth: '560px', lineHeight: 1.6 }}>
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions list */}
|
||||||
|
<div>
|
||||||
|
<p className="label-upper" style={{ marginBottom: '12px' }}>Conversations</p>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-sm text-muted">Loading...</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
padding: '32px', textAlign: 'center',
|
||||||
|
border: '1px dashed var(--border)', borderRadius: 'var(--radius-lg)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<p className="text-sm">No conversations in this project yet</p>
|
||||||
|
<p className="text-xs" style={{ marginTop: '6px' }}>
|
||||||
|
Click "+ New Chat" to start one
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export default function Sidebar({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
projects,
|
projects,
|
||||||
onProjectsChange,
|
onProjectsChange,
|
||||||
|
onSelectProject
|
||||||
}) {
|
}) {
|
||||||
const [chatsOpen, setChatsOpen] = useState(true);
|
const [chatsOpen, setChatsOpen] = useState(true);
|
||||||
const [projectsOpen, setProjectsOpen] = useState(true);
|
const [projectsOpen, setProjectsOpen] = useState(true);
|
||||||
@@ -158,7 +159,7 @@ export default function Sidebar({
|
|||||||
{projects.slice(0, 6).map(project => (
|
{projects.slice(0, 6).map(project => (
|
||||||
<button
|
<button
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={() => onNavigate('all-projects')}
|
onClick={() => {onSelectProject(project); onNavigate('all-projects')}}
|
||||||
className="btn-reset text-xs"
|
className="btn-reset text-xs"
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function useChat({ activeSession, appendMessage, updateLastMessage, refre
|
|||||||
const [lastModel, setLastModel] = useState(null);
|
const [lastModel, setLastModel] = useState(null);
|
||||||
const cancelRef = useRef(null);
|
const cancelRef = useRef(null);
|
||||||
|
|
||||||
const sendMessage = useCallback(async (text, model) => {
|
const sendMessage = useCallback(async (text, model, projectId = null) => {
|
||||||
if (!activeSession || !text.trim() || streaming) return;
|
if (!activeSession || !text.trim() || streaming) return;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -56,6 +56,12 @@ export function useChat({ activeSession, appendMessage, updateLastMessage, refre
|
|||||||
|
|
||||||
// Delayed refresh
|
// Delayed refresh
|
||||||
setTimeout( () => refreshSessions(), 3000);
|
setTimeout( () => refreshSessions(), 3000);
|
||||||
|
|
||||||
|
// Assign project after first message if one was set
|
||||||
|
if (projectId) {
|
||||||
|
updateSession(activeSession.external_id, { projectId })
|
||||||
|
.catch(err => console.warn('[useChat] Failed to assign project:', err.message));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function useSession() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
|
setSessions,
|
||||||
activeSession,
|
activeSession,
|
||||||
messages,
|
messages,
|
||||||
loadingHistory,
|
loadingHistory,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
const { getDB } = require('./index');
|
const { getDB } = require('./index');
|
||||||
const { parseRow } = require('@nexusai/shared');
|
const { parseRow } = require('@nexusai/shared');
|
||||||
|
|
||||||
function createProject({ name, description, colour, icon }) {
|
function createProject({ name, description, colour, icon, projectOnly }) {
|
||||||
const db = getDB();
|
const db = getDB();
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO projects (name, description, colour, icon)
|
INSERT INTO projects (name, description, colour, icon)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`).run(name, description ?? null, colour ?? null, icon ?? null);
|
`).run(name, description ?? null, colour ?? null, icon ?? null, projectOnly ?? 0);
|
||||||
return getProject(result.lastInsertRowid);
|
return getProject(result.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,12 +20,12 @@ 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 }) {
|
function updateProject(id, { name, description, colour, icon, projectOnly }) {
|
||||||
const db = getDB();
|
const db = getDB();
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE projects SET name = ?, description = ?, colour = ?, icon = ?
|
UPDATE projects SET name = ?, description = ?, colour = ?, icon = ?, projectOnly = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(name, description ?? null, colour ?? null, icon ?? null, id);
|
`).run(name, description ?? null, colour ?? null, icon ?? null, projectOnly ?? 0, id);
|
||||||
return getProject(id);
|
return getProject(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { name, description, colour, icon } = req.body;
|
const { name, description, colour, icon, projectOnly } = req.body;
|
||||||
if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
|
if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
|
||||||
try {
|
try {
|
||||||
res.status(201).json(await memory.createProject({ name: name.trim(), description, colour, icon }));
|
res.status(201).json(await memory.createProject({ name: name.trim(), description, colour, icon, projectOnly }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user