From 766b067f53235134c53ede9e129e743d824965db Mon Sep 17 00:00:00 2001 From: 54x1 Date: Mon, 6 Jul 2026 17:56:30 +1000 Subject: [PATCH 1/3] added group chat sessions into named project group folders --- app.py | 4 + core/database.py | 52 +++++++ routes/project_routes.py | 291 +++++++++++++++++++++++++++++++++++++++ static/app.js | 15 +- static/js/sessions.js | 101 ++++++-------- 5 files changed, 400 insertions(+), 63 deletions(-) create mode 100644 routes/project_routes.py diff --git a/app.py b/app.py index f2f1dd0b36..101f9c14c9 100644 --- a/app.py +++ b/app.py @@ -739,6 +739,10 @@ async def activity_heartbeat(): from routes.workspace_routes import setup_workspace_routes app.include_router(setup_workspace_routes()) +# Projects (group chat sessions into named project groups) +from routes.project_routes import setup_project_routes +app.include_router(setup_project_routes()) + # Hardware model fitting (cookbook "What Fits?" tab) from routes.hwfit_routes import setup_hwfit_routes app.include_router(setup_hwfit_routes()) diff --git a/core/database.py b/core/database.py index ade995871a..dca01e3e48 100644 --- a/core/database.py +++ b/core/database.py @@ -1725,6 +1725,33 @@ class CalendarDeletedEvent(TimestampMixin, Base): last_error = Column(Text, nullable=True) +class Project(TimestampMixin, Base): + """A named project group that sessions can be assigned to. + + Projects are the persistent "folder above folders" — every session + belongs to at most one project. The project name is the UI-facing + slug (kebab-case) and description is a free-text label. + """ + __tablename__ = "projects" + + id = Column(String, primary_key=True, index=True) + owner = Column(String, nullable=True, index=True) + name = Column(String, nullable=False) # kebab-case slug + slug = Column(String, nullable=False, unique=True) # canonical slug + description = Column(String, nullable=True, default="") + + # Index for fast owner + name lookups + __table_args__ = ( + Index('ix_projects_owner_slug', 'owner', 'slug'), + ) + + @property + def sessions(self): + """Relationship to sessions assigned to this project.""" + from sqlalchemy.orm import sessionmaker + return None # sessions linked via folder field on Session rows + + class Integration(TimestampMixin, Base): """An external service connection (email, RSS, webhook, etc.).""" __tablename__ = "integrations" @@ -1865,6 +1892,7 @@ def init_db(): _migrate_encrypt_signatures() _migrate_encrypt_endpoint_keys() _migrate_backfill_task_folders() + _migrate_add_projects_table() def _migrate_backfill_task_folders(): @@ -1893,6 +1921,30 @@ def _migrate_backfill_task_folders(): logging.getLogger(__name__).warning(f"task folder backfill: {e}") +def _migrate_add_projects_table(): + """Create the projects table for grouping sessions. Idempotent — + ``Base.metadata.create_all`` creates the table if missing; the migration + call is kept so any future per-row backfill logic has a home.""" + try: + from sqlalchemy import text + with engine.connect() as conn: + tables = [r[0] for r in conn.execute(text( + "SELECT name FROM sqlite_master WHERE type='table'" + ))] + if "projects" not in tables: + conn.execute(text("DROP TABLE IF EXISTS projects")) + Base.metadata.create_all(bind=engine) + logging.getLogger(__name__).info("Migrated: created projects table") + else: + # Ensure the slug column exists (schema evolution guard) + cols = [r[1] for r in conn.execute(text("PRAGMA table_info(projects)"))] + if "slug" not in cols: + conn.execute(text("ALTER TABLE projects ADD COLUMN slug TEXT NOT NULL DEFAULT ''")) + conn.commit() + except Exception as e: + logging.getLogger(__name__).warning(f"projects table migration skipped: {e}") + + def _migrate_chat_messages_fts(): """Create and backfill the session transcript FTS index for SQLite.""" if not DATABASE_URL.startswith("sqlite"): diff --git a/routes/project_routes.py b/routes/project_routes.py new file mode 100644 index 0000000000..9b24ec3dff --- /dev/null +++ b/routes/project_routes.py @@ -0,0 +1,291 @@ +# routes/project_routes.py +"""Projects API — group chat sessions into named projects with persistent tracking. + +Projects are user-owned, and sessions are assigned to a project via the +session's ``folder`` field prefixed with ``[project/]``. +""" + +import uuid +import logging +from typing import Dict, Any, Optional + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from core.database import SessionLocal, Project, Session as DbSession, utcnow_naive +from src.auth_helpers import effective_user, owner_filter, require_user + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Request models +# --------------------------------------------------------------------------- + +class ProjectCreate(BaseModel): + name: str # human-readable name + description: Optional[str] = "" + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + + +class ProjectAssign(BaseModel): + """Assign (or remove) a session from a project.""" + session_id: str + project_id: Optional[str] = None # None = remove from project + + +class ProjectSessionInfo(BaseModel): + """Minimal session info returned in project detail.""" + id: str + name: str + model: str + updated_at: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _slugify(name: str) -> str: + """Turn a display name into a kebab-case slug.""" + import re + slug = name.lower().strip() + slug = re.sub(r"[^a-z0-9]+", "-", slug) + slug = slug.strip("-") + return slug[:64] or str(uuid.uuid4())[:8] + + +def _project_to_dict(p: Project) -> Dict[str, Any]: + db = SessionLocal() + try: + q = db.query(DbSession).filter(DbSession.folder == f"project:{p.slug}") + sessions = q.all() + session_count = len(sessions) + finally: + db.close() + return { + "id": p.id, + "name": p.name, + "slug": p.slug, + "description": p.description or "", + "session_count": session_count, + "created_at": p.created_at.isoformat() if p.created_at else None, + "updated_at": p.updated_at.isoformat() if p.updated_at else None, + } + + +def _session_to_project_info(s: DbSession) -> Dict[str, Any]: + return { + "id": s.id, + "name": s.name, + "model": s.model, + "updated_at": s.updated_at.isoformat() if s.updated_at else None, + } + + +# --------------------------------------------------------------------------- +# Route setup +# --------------------------------------------------------------------------- + +def setup_project_routes(): + router = APIRouter(prefix="/api/projects", tags=["projects"]) + + @router.get("/") + def list_projects(request: Request): + """List all projects for the current user.""" + user = effective_user(request) + db = SessionLocal() + try: + q = db.query(Project) + q = owner_filter(q, Project, user, include_shared=False) + projects = q.order_by(Project.updated_at.desc()).all() + finally: + db.close() + return [_project_to_dict(p) for p in projects] + + @router.post("/", response_model=dict) + def create_project(request: Request, body: ProjectCreate): + """Create a new project.""" + user = effective_user(request) + slug = _slugify(body.name) + db = SessionLocal() + try: + # Check for duplicate slug among this user's projects + dup = owner_filter(db.query(Project), Project, user, include_shared=False).filter( + Project.slug == slug + ).first() + if dup: + # Append a number to make it unique + slug = f"{slug}-{uuid.uuid4().hex[:4]}" + p = Project( + id=uuid.uuid4().hex, + owner=user, + name=body.name, + slug=slug, + description=body.description, + ) + db.add(p) + db.commit() + db.refresh(p) + except Exception: + db.rollback() + raise HTTPException(400, "Failed to create project") + finally: + db.close() + return _project_to_dict(p) + + @router.patch("/{project_id}", response_model=dict) + def update_project(request: Request, project_id: str, body: ProjectUpdate): + """Update a project's name or description.""" + user = effective_user(request) + db = SessionLocal() + try: + p = owner_filter(db.query(Project), Project, user, include_shared=False).filter( + Project.id == project_id + ).first() + if not p: + raise HTTPException(404, "Project not found") + if body.name is not None: + new_slug = _slugify(body.name) + if new_slug != p.slug: + # Check collision + dup = owner_filter(db.query(Project), Project, user, include_shared=False).filter( + Project.slug == new_slug + ).first() + if dup: + new_slug = f"{new_slug}-{uuid.uuid4().hex[:4]}" + # Update session folders + for s in db.query(DbSession).filter(DbSession.folder == f"project:{p.slug}").all(): + s.folder = f"project:{new_slug}" + p.slug = new_slug + p.name = body.name + if body.description is not None: + p.description = body.description + p.updated_at = utcnow_naive() + db.commit() + db.refresh(p) + except HTTPException: + raise + except Exception: + db.rollback() + raise HTTPException(400, "Failed to update project") + finally: + db.close() + return _project_to_dict(p) + + @router.delete("/{project_id}") + def delete_project(request: Request, project_id: str): + """Delete a project. Associated sessions are unassigned.""" + user = effective_user(request) + db = SessionLocal() + try: + p = owner_filter(db.query(Project), Project, user, include_shared=False).filter( + Project.id == project_id + ).first() + if not p: + raise HTTPException(404, "Project not found") + # Unassign sessions + for s in db.query(DbSession).filter(DbSession.folder == f"project:{p.slug}").all(): + s.folder = None + db.delete(p) + db.commit() + except HTTPException: + raise + except Exception: + db.rollback() + raise HTTPException(400, "Failed to delete project") + finally: + db.close() + return {"ok": True} + + @router.get("/{project_id}/sessions") + def get_project_sessions(request: Request, project_id: str): + """List sessions belonging to a project.""" + user = effective_user(request) + db = SessionLocal() + try: + p = owner_filter(db.query(Project), Project, user, include_shared=False).filter( + Project.id == project_id + ).first() + if not p: + raise HTTPException(404, "Project not found") + sessions = owner_filter( + db.query(DbSession).filter(DbSession.folder == f"project:{p.slug}"), + DbSession, user + ).order_by(DbSession.updated_at.desc()).all() + finally: + db.close() + return [_session_to_project_info(s) for s in sessions] + + @router.post("/{project_id}/assign") + def assign_session(request: Request, project_id: str, body: dict): + """Assign a session to this project. + + body can be {"session_id": "..."} or a list of session IDs. + """ + user = effective_user(request) + db = SessionLocal() + try: + p = owner_filter(db.query(Project), Project, user, include_shared=False).filter( + Project.id == project_id + ).first() + if not p: + raise HTTPException(404, "Project not found") + session_ids = body.get("session_ids") or [body.get("session_id")] + if not session_ids: + raise HTTPException(400, "Provide session_id or session_ids") + assigned = 0 + for sid in session_ids: + s = owner_filter(db.query(DbSession), DbSession, user).filter( + DbSession.id == sid + ).first() + if s: + s.folder = f"project:{p.slug}" + assigned += 1 + p.updated_at = utcnow_naive() + db.commit() + except HTTPException: + raise + except Exception: + db.rollback() + raise HTTPException(400, "Failed to assign sessions") + finally: + db.close() + return {"ok": True, "assigned": assigned} + + @router.post("/{project_id}/unassign") + def unassign_session(request: Request, project_id: str, body: dict): + """Remove sessions from this project.""" + user = effective_user(request) + db = SessionLocal() + try: + p = owner_filter(db.query(Project), Project, user, include_shared=False).filter( + Project.id == project_id + ).first() + if not p: + raise HTTPException(404, "Project not found") + session_ids = body.get("session_ids") or [body.get("session_id")] + if not session_ids: + raise HTTPException(400, "Provide session_id or session_ids") + unassigned = 0 + for sid in session_ids: + s = owner_filter(db.query(DbSession), DbSession, user).filter( + DbSession.id == sid, DbSession.folder == f"project:{p.slug}" + ).first() + if s: + s.folder = None + unassigned += 1 + db.commit() + except HTTPException: + raise + except Exception: + db.rollback() + raise HTTPException(400, "Failed to unassign sessions") + finally: + db.close() + return {"ok": True, "unassigned": unassigned} + + return router diff --git a/static/app.js b/static/app.js index 927ee8b28c..4db80a1131 100644 --- a/static/app.js +++ b/static/app.js @@ -1258,18 +1258,19 @@ function initializeEventListeners() { document.addEventListener('click', () => { sortDropdown.style.display = 'none'; }); sortDropdown.addEventListener('click', (e) => e.stopPropagation()); - // Sort mode options (newest, oldest, last active) — toggleable + // Sort mode options — controls within-folder ordering. + // Clicking 'By Folder' keeps folders but also sets within-folder sorting. sortDropdown.querySelectorAll('.sort-option').forEach(opt => { opt.addEventListener('click', () => { const mode = opt.dataset.sort; - const current = sessionModule.getSortMode(); + const current = sessionModule.getFolderSortMode(); // Toggle: clicking the active sort reverts to manual if (current === mode) { - sessionModule.setSortMode(null); + sessionModule.setFolderSortMode(null); sortDropdown.style.display = 'none'; uiModule.showToast('Manual order'); } else { - sessionModule.setSortMode(mode); + sessionModule.setFolderSortMode(mode); sortDropdown.style.display = 'none'; uiModule.showToast(`Sorted: ${opt.textContent.trim().toLowerCase()}`); } @@ -1277,9 +1278,9 @@ function initializeEventListeners() { }); }); - // Sync checkmarks on sort options + // Sync checkmarks on sort options — uses folder sort mode function _syncSortChecks() { - const current = sessionModule.getSortMode(); + const current = sessionModule.getFolderSortMode(); sortDropdown.querySelectorAll('.sort-option').forEach(o => { const check = o.querySelector('.sort-check') || document.createElement('span'); check.className = 'sort-check'; @@ -1312,7 +1313,7 @@ function initializeEventListeners() { const data = await res.json(); if (!res.ok) throw new Error(data.detail || 'Auto-sort failed'); if (data.status === 'ok') { - sessionModule.setSortMode(null); // clear sort — tidy creates manual folder order + sessionModule.setFolderSortMode(null); // clear within-folder sort — tidy creates manual folder order _syncSortChecks(); if (skipLlm) { // No-AI path: just report what got cleaned. No "unfiled diff --git a/static/js/sessions.js b/static/js/sessions.js index fb13af3b8e..26b5d244e7 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -27,6 +27,7 @@ const FOLDER_MAX_VISIBLE = 5; let _showAllSessions = false; let _expandedFolders = {}; // folderName -> true if "show more" clicked let _sortMode = Storage.get('odysseus-session-sort') || 'active'; // default to last active +let _folderSortMode = Storage.get('odysseus-folder-sort') || 'active'; // secondary sort within folders let _autoCreateInProgress = false; // guard against recursive auto-create const _INCOGNITO_SESSIONS_KEY = 'ody-incognito-sessions'; // sessionStorage key for incognito session IDs const _isMac = /Mac|iPhone|iPad/.test(navigator.platform); @@ -1065,59 +1066,27 @@ function _renderSessionListImpl() { const _frag = document.createDocumentFragment(); - // ── Flat sort modes: ignore folders, show one ordered list. ── - // Folders are only shown when _sortMode === 'group' (or null/empty - // for manual mode). This keeps the picker simple: a folder-grouped - // view is one of the sort choices, alongside Last Active / Newest. - if (_sortMode && _sortMode !== 'group') { - orderedSessions.sort((a, b) => { - if (_sortMode === 'newest') return (b.created_at || '').localeCompare(a.created_at || ''); - // "Last active" sorts by the last actual MESSAGE, not updated_at — - // updated_at is bumped by renames / model swaps / folder moves, which - // made the order feel random. Fall back to updated_at/created_at for - // older rows that predate the last_message_at backfill. - if (_sortMode === 'active') { - const av = a.last_message_at || a.updated_at || a.created_at || ''; - const bv = b.last_message_at || b.updated_at || b.created_at || ''; - return bv.localeCompare(av); - } - return 0; - }); - // Favorites are a global pinned block above date buckets, not just - // promoted within the day they belong to. - const allFlat = [ - ...orderedSessions.filter(s => s.is_important), - ...orderedSessions.filter(s => !s.is_important), - ]; - - const limit = _showAllSessions ? allFlat.length : SIDEBAR_MAX_VISIBLE; - const visible = allFlat.slice(0, limit); - const activeIdx = allFlat.findIndex(s => s.id === currentSessionId); - if (!_showAllSessions && activeIdx >= limit) visible.push(allFlat[activeIdx]); - - const visibleFavorites = visible.filter(s => s.is_important); - const visibleRegular = visible.filter(s => !s.is_important); - _appendFavoriteSessionItems(_frag, visibleFavorites); - _appendSessionItemsWithDateHeaders(_frag, visibleRegular); - - if (allFlat.length > SIDEBAR_MAX_VISIBLE) { - const remaining = allFlat.length - SIDEBAR_MAX_VISIBLE; - const toggleBtn = document.createElement('button'); - toggleBtn.className = 'session-show-more-btn'; - toggleBtn.textContent = _showAllSessions ? 'Show less' : `Show ${remaining} more`; - toggleBtn.addEventListener('click', (e) => { - e.stopPropagation(); - _showAllSessions = !_showAllSessions; - renderSessionList(); - }); - _frag.appendChild(toggleBtn); - } - - list.innerHTML = ''; - list.appendChild(_frag); - _postRenderSessionList(list); - return; - } + // ── Always render folder structure; sort within folders using _folderSortMode. ── + // The top-level _sortMode is kept for backward compat but always resolves to + // folder-aware rendering. _folderSortMode controls the within-folder order + // (active | newest | manual). + if (!_folderSortMode) _folderSortMode = 'active'; + + // Build sort comparator for within-folder ordering + const _folderCompare = (a, b) => { + if (_folderSortMode === 'newest') return (b.created_at || '').localeCompare(a.created_at || ''); + // "Last active" sorts by the last actual MESSAGE, not updated_at — + // updated_at is bumped by renames / model swaps / folder moves, which + // made the order feel random. Fall back to updated_at/created_at for + // older rows that predate the last_message_at backfill. + if (_folderSortMode === 'active') { + const av = a.last_message_at || a.updated_at || a.created_at || ''; + const bv = b.last_message_at || b.updated_at || b.created_at || ''; + return bv.localeCompare(av); + } + // manual / default: keep current (API) order + return 0; + }; // ── Group / manual mode: render folders, then unfiled sessions. ── const folderState = loadFolderState(); @@ -1143,6 +1112,17 @@ function _renderSessionListImpl() { starPartition(unfiled); Object.values(folders).forEach(arr => starPartition(arr)); + // Apply within-folder sorting (after starred pinning) + const sortWithin = (arr) => { + const starred = arr.filter(s => s.is_important); + const rest = arr.filter(s => !s.is_important); + rest.sort(_folderCompare); + arr.length = 0; + arr.push(...starred, ...rest); + }; + sortWithin(unfiled); + Object.values(folders).forEach(arr => sortWithin(arr)); + // Render folders first (above unfiled sessions) const savedFolderOrder = loadFolderOrder(); const allFolderNames = Object.keys(folders); @@ -1285,7 +1265,7 @@ function _renderSessionListImpl() { visibleUnfiled.push(unfiled[activeInUnfiled]); } - // Wrap in "Unsorted" folder if real folders exist + // Wrap in "General" folder if real folders exist let unfiledTarget = _frag; if (hasFolders && unfiled.length > 0) { const unsortedDiv = document.createElement('div'); @@ -1306,7 +1286,7 @@ function _renderSessionListImpl() { unsortedHeader.appendChild(toggle); const nameSpan = document.createElement('span'); nameSpan.className = 'folder-name'; - nameSpan.textContent = 'Unsorted'; + nameSpan.textContent = 'General'; unsortedHeader.appendChild(nameSpan); const countSpan = document.createElement('span'); countSpan.className = 'folder-count'; @@ -3447,6 +3427,13 @@ export function setSortMode(mode) { renderSessionList(); } +export function getFolderSortMode() { return _folderSortMode; } +export function setFolderSortMode(mode) { + _folderSortMode = mode || 'active'; + Storage.set('odysseus-folder-sort', _folderSortMode); + renderSessionList(); +} + export function setSessionHasDocs(sessionId, hasDocs) { const s = sessions.find(s => s.id === sessionId); if (s && s.has_documents !== hasDocs) { @@ -3484,7 +3471,9 @@ const sessionModule = { closeArchive, setSessionHasDocs, getSortMode, - setSortMode + setSortMode, + getFolderSortMode, + setFolderSortMode }; export { updateModelPicker }; From 3492d52d3c3339e746ddba0e3a634e895d355972 Mon Sep 17 00:00:00 2001 From: 54x1 <26955286+54x1@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:35:12 +1000 Subject: [PATCH 2/3] feat:rename folders and add new folders --- static/app.js | 144 ++++++++++++++++++++++++++++++++- static/index.html | 6 +- static/js/sessions.js | 183 ++++++++++++++++++++++++++++++++++-------- static/style.css | 22 +++++ 4 files changed, 315 insertions(+), 40 deletions(-) diff --git a/static/app.js b/static/app.js index 4db80a1131..2886d5c1c8 100644 --- a/static/app.js +++ b/static/app.js @@ -155,9 +155,13 @@ _refreshDefaultChat(); async function _createDirectChatFromPreferredModel() { if (!sessionModule) return false; + // Check if a folder was pre-set (e.g. double-click a project folder) + let targetFolder = sessionStorage.getItem('ody-pending-folder'); + if (targetFolder) sessionStorage.removeItem('ody-pending-folder'); + const pending = sessionModule.getPendingChat && sessionModule.getPendingChat(); if (pending && pending.url && pending.modelId && pending.endpointId) { - sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId); + sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId, targetFolder); return true; } @@ -165,20 +169,20 @@ async function _createDirectChatFromPreferredModel() { const currentId = sessionModule.getCurrentSessionId(); const current = sessions.find(s => s.id === currentId); if (current && current.endpoint_url && current.model && current.endpoint_id) { - sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id); + sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id, targetFolder); return true; } const dc = await _refreshDefaultChat(); if (dc) { - sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id); + sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, targetFolder); return true; } const withModel = sessions.filter(s => s.endpoint_url && s.model); if (withModel.length > 0) { const last = withModel[0]; // sessions are sorted by recent - sessionModule.createDirectChat(last.endpoint_url, last.model, last.endpoint_id); + sessionModule.createDirectChat(last.endpoint_url, last.model, last.endpoint_id, targetFolder); return true; } @@ -461,6 +465,114 @@ function initializeEventListeners() { }); } + // Export: Move to Project — shows a list of projects to choose from + const exportMoveProjectBtn = el('export-move-to-project-btn'); + if (exportMoveProjectBtn) { + exportMoveProjectBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + if (!sessionModule) return; + const sessionId = sessionModule.getCurrentSessionId(); + if (!sessionId) { uiModule.showToast('Nothing to move — create a chat first'); return; } + // Load projects + try { await sessionModule.loadProjects(); } catch {} + const allProjects = window._projectsCache || []; + if (allProjects.length === 0) { + uiModule.showToast('No projects yet — create one from the sidebar'); + exportMenu.classList.remove('open'); + return; + } + // Build a list of project names, find current project if assigned + const currentSession = sessionModule.getSessions().find(s => s.id === sessionId); + const currentFolder = currentSession?.folder || ''; + // Show project selection via a custom modal + const project = await _showProjectSelector(allProjects, currentFolder); + if (!project) return; + try { + await sessionModule.assignSessionToProject(project.id, sessionId); + uiModule.showToast(`Moved to "${project.name}"`); + if (sessionModule.loadSessions) await sessionModule.loadSessions(); + } catch (err) { + uiModule.showError('Failed to move: ' + err.message); + } + exportMenu.classList.remove('open'); + }); + } + + // ── Project selector modal helper ── + async function _showProjectSelector(projects, currentFolder) { + return new Promise((resolve) => { + let overlay = document.getElementById('project-selector-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'project-selector-overlay'; + overlay.className = 'modal'; + overlay.style.cssText = 'z-index:9999;display:none;'; + overlay.innerHTML = + ''; + document.body.appendChild(overlay); + } + const listEl = document.getElementById('project-selector-list'); + listEl.innerHTML = ''; + // Render project items + projects.forEach(p => { + const isCurrent = currentFolder === `project:${p.slug}`; + const item = document.createElement('div'); + item.style.cssText = ` + padding: 10px 12px; cursor: pointer; border-radius: 6px; margin: 2px 0; + display: flex; align-items: center; gap: 8px; + opacity: ${isCurrent ? '0.5' : '1'}; + transition: background 0.15s; + `; + item.innerHTML = ` + +
+
${p.name}
+ ${p.description ? `
${p.description}
` : ''} +
+ ${isCurrent ? 'Current' : ''} + `; + item.addEventListener('mouseenter', () => { if (!isCurrent) item.style.background = 'var(--hover-bg,rgba(255,255,255,0.06))'; }); + item.addEventListener('mouseleave', () => { item.style.background = 'transparent'; }); + item.addEventListener('click', () => { cleanup(p); }); + listEl.appendChild(item); + }); + // Overlay styling + overlay.style.background = 'rgba(0,0,0,0.5)'; + overlay.style.display = 'flex'; + overlay.style.alignItems = 'center'; + overlay.style.justifyContent = 'center'; + overlay.style.position = 'fixed'; + overlay.style.inset = '0'; + // Position the modal content + const modalContent = overlay.querySelector('.modal-content'); + modalContent.style.position = 'relative'; + + const cancelBtn = document.getElementById('project-selector-cancel'); + function cleanup(project) { + overlay.style.display = 'none'; + cancelBtn.removeEventListener('click', onCancel); + overlay.removeEventListener('click', onBackdrop); + document.removeEventListener('keydown', onKey); + resolve(project || null); + } + function onCancel() { cleanup(null); } + function onBackdrop(ev) { if (ev.target === overlay) cleanup(null); } + function onKey(ev) { if (ev.key === 'Escape') cleanup(null); } + + cancelBtn.addEventListener('click', onCancel); + overlay.addEventListener('click', onBackdrop); + document.addEventListener('keydown', onKey); + }); + } + // Rename session from top bar const exportRenameBtn = el('export-rename-btn'); if (exportRenameBtn) { @@ -1152,6 +1264,30 @@ function initializeEventListeners() { }); } + // Create Project button + const createProjectBtn = el('create-project-btn'); + if (createProjectBtn) { + createProjectBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + if (!sessionModule) return; + const name = await uiModule.styledPrompt('Name your project:', { + title: 'New Project', + placeholder: 'e.g. Website Redesign, Research Notes', + confirmText: 'Create', + }); + if (!name || !name.trim()) return; + try { + const result = await sessionModule.createProject(name.trim()); + if (result) { + uiModule.showToast(`Project "${result.name}" created`); + if (sessionModule.renderSessionList) sessionModule.renderSessionList(); + } + } catch (err) { + uiModule.showError('Failed to create project: ' + err.message); + } + }); + } + const toolArchiveBtn = el('tool-archive-btn'); if (toolArchiveBtn) { toolArchiveBtn.addEventListener('click', () => { diff --git a/static/index.html b/static/index.html index a7d0389e51..3927a361b3 100644 --- a/static/index.html +++ b/static/index.html @@ -731,6 +731,10 @@

Save / Share

manage + -
Odysseus Chat
Rename
Copy Chat
PDF
Save to Documents
+
Odysseus Chat
Rename
Copy Chat
PDF
Save to Documents
Odysseus
Welcome, type /setup to get started.
diff --git a/static/js/sessions.js b/static/js/sessions.js index 26b5d244e7..a8de5efa11 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -373,6 +373,82 @@ function getFolderNames() { return Array.from(names).sort(); } +// ── Project state & API helpers ── +let _projectsCache = []; // all project objects from /api/projects/ +let _projectsCacheReady = false; // whether the cache has been populated +let _currentProjectFolder = null; // folder slug the user clicked "new chat in" + +/** Fetch the project list from the API (cached). */ +export async function loadProjects() { + if (_projectsCacheReady) return; + try { + const res = await fetch(`${API_BASE}/api/projects/`); + if (res.ok) _projectsCache = await res.json(); + } catch (e) { + console.warn('Failed to load projects:', e); + } + _projectsCacheReady = true; + // Expose cache on window for other modules (e.g., export dropdown handler) + window._projectsCache = _projectsCache; + // Update visibility of "Move to Project" in the export dropdown + updateMoveToProjectVisibility(); +} + +/** Show the "Move to Project" export button when projects exist and a session is active. */ +function updateMoveToProjectVisibility() { + const btn = document.getElementById('export-move-to-project-btn'); + if (!btn) return; + const hasProjects = (_projectsCache && _projectsCache.length > 0); + const hasSession = currentSessionId != null; + btn.style.display = (hasProjects && hasSession) ? '' : 'none'; +} + +/** Create a new project and return it. */ +export async function createProject(name, description = '') { + const res = await fetch(`${API_BASE}/api/projects/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }), + }); + const data = await res.json(); + // Refresh projects cache so the new project appears immediately + _projectsCacheReady = false; + await loadProjects(); + updateMoveToProjectVisibility(); + return data; +} + +/** Assign a session to a project by project ID. */ +export async function assignSessionToProject(projectId, sessionId) { + const res = await fetch(`${API_BASE}/api/projects/${projectId}/assign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId }), + }); + return res.json(); +} + +/** Resolve a folder string like "project:my-slug" to a friendly project name. */ +function resolveFolderName(folder) { + if (!folder || !folder.startsWith('project:')) return folder; + const slug = folder.slice(8); // strip "project:" + const project = _projectsCache.find(p => p.slug === slug); + return project ? project.name : folder; +} + +/** Check whether a folder is a project folder. */ +function isProjectFolder(folder) { + return folder && folder.startsWith('project:'); +} + +/** Get the project ID for a given project folder string. */ +function getProjectIdForFolder(folder) { + if (!isProjectFolder(folder)) return null; + const slug = folder.slice(8); + const project = _projectsCache.find(p => p.slug === slug); + return project ? project.id : null; +} + /** Move a session to a folder via the API. */ async function moveToFolder(sessionId, folderName) { const fd = new FormData(); @@ -415,7 +491,10 @@ function buildFolderSubmenu(sessionId, currentFolder, dropdown) { const opt = document.createElement('div'); opt.className = 'dropdown-item-compact'; if (f === currentFolder) opt.style.opacity = '0.5'; - opt.textContent = f; + opt.textContent = resolveFolderName(f) || f; + if (isProjectFolder(f)) { + opt.innerHTML = '' + resolveFolderName(f); + } opt.addEventListener('click', async (e) => { e.stopPropagation(); await moveToFolder(sessionId, f); @@ -1156,9 +1235,21 @@ function _renderSessionListImpl() { toggle.textContent = collapsed ? '\u25B6' : '\u25BC'; header.appendChild(toggle); + // Mark project folders with a data attribute and CSS class + const _isProject = isProjectFolder(folderName); + if (_isProject) folderDiv.classList.add('project-folder'); + const nameSpan = document.createElement('span'); nameSpan.className = 'folder-name'; - nameSpan.textContent = folderName; + // Show project icon + friendly name for project folders + const displayName = _isProject ? resolveFolderName(folderName) : folderName; + if (_isProject) { + const projIcon = document.createElement('span'); + projIcon.className = 'project-folder-icon'; + projIcon.innerHTML = ''; + header.appendChild(projIcon); + } + nameSpan.textContent = displayName; header.appendChild(nameSpan); const countSpan = document.createElement('span'); @@ -1166,26 +1257,28 @@ function _renderSessionListImpl() { countSpan.textContent = `(${folders[folderName].length})`; header.appendChild(countSpan); - // Delete folder button - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'folder-delete-btn'; - deleteBtn.textContent = '\u00d7'; - deleteBtn.title = 'Delete folder and all sessions'; - deleteBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - const count = folders[folderName].length; - if (!await uiModule.styledConfirm(`Delete folder "${folderName}" and all ${count} session(s) inside it?`, { confirmText: 'Delete', danger: true })) return; - for (const s of folders[folderName]) { - try { - await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' }); - _deselectCurrentSession(s.id); - } catch (err) { - console.error('Failed to delete session:', s.id, err); + // Delete folder button — only for non-project folders + if (!_isProject) { + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'folder-delete-btn'; + deleteBtn.textContent = '\u00d7'; + deleteBtn.title = 'Delete folder and all sessions'; + deleteBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + const count = folders[folderName].length; + if (!await uiModule.styledConfirm(`Delete folder "${folderName}" and all ${count} session(s) inside it?`, { confirmText: 'Delete', danger: true })) return; + for (const s of folders[folderName]) { + try { + await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' }); + _deselectCurrentSession(s.id); + } catch (err) { + console.error('Failed to delete session:', s.id, err); + } } - } - await loadSessions(); - }); - header.appendChild(deleteBtn); + await loadSessions(); + }); + header.appendChild(deleteBtn); + } let _folderTouchMoved = false; header.addEventListener('touchstart', () => { _folderTouchMoved = false; }, { passive: true }); @@ -1201,19 +1294,30 @@ function _renderSessionListImpl() { renderSessionList(); }); - // Allow renaming folder via double-click - header.addEventListener('dblclick', async (e) => { - e.stopPropagation(); - if (e.target.closest('.folder-delete-btn')) return; - const newName = await styledPrompt('Rename folder:', { - title: 'Rename folder', - defaultValue: folderName, - confirmText: 'Rename', + // Allow renaming folder via double-click — not project folders + if (!_isProject) { + header.addEventListener('dblclick', async (e) => { + e.stopPropagation(); + if (e.target.closest('.folder-delete-btn')) return; + const newName = await styledPrompt('Rename folder:', { + title: 'Rename folder', + defaultValue: folderName, + confirmText: 'Rename', + }); + if (!newName || !newName.trim() || newName.trim() === folderName) return; + const promises = folders[folderName].map(s => moveToFolder(s.id, newName.trim())); + Promise.all(promises).then(() => loadSessions()); }); - if (!newName || !newName.trim() || newName.trim() === folderName) return; - const promises = folders[folderName].map(s => moveToFolder(s.id, newName.trim())); - Promise.all(promises).then(() => loadSessions()); - }); + } else { + // Double-click a project folder → create a new chat inside it + header.addEventListener('dblclick', (e) => { + e.stopPropagation(); + // Store the target folder so the brand button handler picks it up + sessionStorage.setItem('ody-pending-folder', folderName); + const brandBtn = document.getElementById('sidebar-brand-btn'); + if (brandBtn) brandBtn.click(); + }); + } folderDiv.appendChild(header); @@ -1625,6 +1729,8 @@ export async function loadSessions() { fetched = await res.json(); } sessions = _normalizeSessionsList(fetched); + // Load projects cache so we can resolve folder names during render + await loadProjects(); renderSessionList(); const sessionsSection = uiModule.el('sessions-section'); @@ -2077,7 +2183,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru // Pending session — stored locally until the first message is sent let _pendingChat = null; // { url, modelId, endpointId } -export function createDirectChat(url, modelId, endpointId) { +export function createDirectChat(url, modelId, endpointId, folder = null) { _sessionNavToken++; // Detach any active stream so it doesn't interfere with the new chat if (window.chatModule && window.chatModule.detachCurrentStream) { @@ -2091,7 +2197,9 @@ export function createDirectChat(url, modelId, endpointId) { } // Don't hit the API — just store the model info and prepare the UI - _pendingChat = { url, modelId, endpointId }; + _pendingChat = { url, modelId, endpointId, folder }; + // Also store the folder in sessionStorage so the brand button handler can read it + sessionStorage.setItem('ody-pending-folder', folder || ''); _skipAutoSelect = true; _suppressNextSessionLoading = true; currentSessionId = null; @@ -2155,6 +2263,9 @@ export async function materializePendingSession() { if (pending.endpointId) { fd.append('endpoint_id', pending.endpointId); } + if (pending.folder) { + fd.append('folder', pending.folder); + } let res; try { @@ -2234,6 +2345,8 @@ export function setCurrentSessionId(id) { el.classList.remove('active-session', 'active'); }); } + // Update "Move to Project" visibility when a session is selected/deselected + updateMoveToProjectVisibility(); } // Session list keyboard navigation: arrows to move, Delete to delete diff --git a/static/style.css b/static/style.css index d5de9a4659..dc5ecf970e 100644 --- a/static/style.css +++ b/static/style.css @@ -1621,6 +1621,28 @@ body.bg-pattern-sparkles { .session-folder-content { padding-left: 4px; } .session-folder-content .list-item { border-bottom: 1px solid color-mix(in srgb, var(--fg) 7%, transparent); } .session-folder-content .list-item:last-child { border-bottom: none; } + /* ── Project folder distinction ── */ + .project-folder .session-folder-header { + color: color-mix(in srgb, var(--red) 60%, var(--fg)); + font-weight: 700; + } + .project-folder .session-folder-header:hover { + color: var(--red); + background: color-mix(in srgb, var(--red) 6%, transparent); + } + .project-folder-icon { + display: inline-flex; align-items: center; margin-right: 2px; + opacity: 0.7; color: var(--red); + } + .project-folder .folder-drag-handle { cursor: default; opacity: 0; } + .project-folder .folder-delete-btn { display: none; } + .project-folder .session-folder-header::after { + content: ''; + flex-shrink: 0; + width: 6px; height: 6px; border-radius: 50%; + background: var(--red); + opacity: 0.5; + } .session-show-more-btn { display: block; width: 100%; From 4a0f0a546b081fec8a3c9e3e459498ec0922557b Mon Sep 17 00:00:00 2001 From: 54x1 <54x1@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:34:39 +1000 Subject: [PATCH 3/3] feat: add 'New chat' button to project folder headers - Shows a '+' button on project folder headers to create a new chat directly inside that project folder - Button appears on hover, styled in project accent color - Reuses existing ody-pending-folder sessionStorage flow for folder assignment on session creation --- static/js/sessions.js | 16 ++++++++++++++++ static/style.css | 26 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/static/js/sessions.js b/static/js/sessions.js index a8de5efa11..88afe46f3a 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -1257,6 +1257,22 @@ function _renderSessionListImpl() { countSpan.textContent = `(${folders[folderName].length})`; header.appendChild(countSpan); + // New chat button — only for project folders + if (_isProject) { + const newChatBtn = document.createElement('button'); + newChatBtn.className = 'folder-new-chat-btn'; + newChatBtn.textContent = '+'; + newChatBtn.title = 'New chat in this project'; + newChatBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + // Store the target folder so the brand button handler picks it up + sessionStorage.setItem('ody-pending-folder', folderName); + const brandBtn = document.getElementById('sidebar-brand-btn'); + if (brandBtn) brandBtn.click(); + }); + header.appendChild(newChatBtn); + } + // Delete folder button — only for non-project folders if (!_isProject) { const deleteBtn = document.createElement('button'); diff --git a/static/style.css b/static/style.css index dc5ecf970e..a6ca7a8381 100644 --- a/static/style.css +++ b/static/style.css @@ -1643,6 +1643,32 @@ body.bg-pattern-sparkles { background: var(--red); opacity: 0.5; } + .folder-new-chat-btn { + display: none; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; height: 22px; + margin-left: auto; + padding: 0; + border: none; + border-radius: 5px; + background: color-mix(in srgb, var(--red) 25%, transparent); + color: color-mix(in srgb, var(--red) 70%, var(--fg)); + font-size: 15px; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: background 0.15s, color 0.15s, opacity 0.15s; + opacity: 0; + } + .session-folder-header:hover .folder-new-chat-btn { opacity: 0.5; } + .folder-new-chat-btn:hover { + opacity: 1 !important; + background: color-mix(in srgb, var(--red) 40%, transparent); + color: var(--red); + } + .project-folder .folder-new-chat-btn { display: inline-flex; } .session-show-more-btn { display: block; width: 100%;