feat: Sprint 15 — session projects, code copy button, tool card toggle
Session projects: named groups for organizing sessions. Project filter bar with chips between search and session list. Create/rename/delete projects, assign sessions via folder icon dropdown. Stored in projects.json, project_id on Session model. 5 new API endpoints. Code block copy button: every code block gets a Copy button in the language header (or top-right for plain blocks). Clipboard API with "Copied!" feedback. Tool card expand/collapse: messages with 2+ tool cards get an "Expand all / Collapse all" toggle above the card group. 13 new tests (237 total), all passing. No regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ WORKSPACES_FILE = STATE_DIR / 'workspaces.json'
|
||||
SESSION_INDEX_FILE = SESSION_DIR / '_index.json'
|
||||
SETTINGS_FILE = STATE_DIR / 'settings.json'
|
||||
LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt'
|
||||
PROJECTS_FILE = STATE_DIR / 'projects.json'
|
||||
|
||||
# ── Hermes agent directory discovery ─────────────────────────────────────────
|
||||
def _discover_agent_dir() -> Path:
|
||||
|
||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
import api.config as _cfg
|
||||
from api.config import (
|
||||
SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX,
|
||||
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL
|
||||
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE
|
||||
)
|
||||
from api.workspace import get_last_workspace
|
||||
|
||||
@@ -34,8 +34,8 @@ def _write_session_index():
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, **kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived)
|
||||
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, **kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None
|
||||
@property
|
||||
def path(self): return SESSION_DIR / f'{self.session_id}.json'
|
||||
def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index()
|
||||
@@ -44,7 +44,7 @@ class Session:
|
||||
p = SESSION_DIR / f'{sid}.json'
|
||||
if not p.exists(): return None
|
||||
return cls(**json.loads(p.read_text(encoding='utf-8')))
|
||||
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived}
|
||||
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id}
|
||||
|
||||
def get_session(sid):
|
||||
with LOCK:
|
||||
@@ -114,3 +114,19 @@ def title_from(messages, fallback='Untitled'):
|
||||
if text:
|
||||
return text[:64]
|
||||
return fallback
|
||||
|
||||
|
||||
# ── Project helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def load_projects():
|
||||
"""Load project list from disk. Returns list of project dicts."""
|
||||
if not PROJECTS_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(PROJECTS_FILE.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def save_projects(projects):
|
||||
"""Write project list to disk."""
|
||||
PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
@@ -23,6 +23,7 @@ from api.helpers import require, bad, safe_resolve, j, t, read_body
|
||||
from api.models import (
|
||||
Session, get_session, new_session, all_sessions, title_from,
|
||||
_write_session_index, SESSION_INDEX_FILE,
|
||||
load_projects, save_projects,
|
||||
)
|
||||
from api.workspace import (
|
||||
load_workspaces, save_workspaces, get_last_workspace, set_last_workspace,
|
||||
@@ -93,6 +94,9 @@ def handle_get(handler, parsed):
|
||||
if parsed.path == '/api/sessions':
|
||||
return j(handler, {'sessions': all_sessions()})
|
||||
|
||||
if parsed.path == '/api/projects':
|
||||
return j(handler, {'projects': load_projects()})
|
||||
|
||||
if parsed.path == '/api/session/export':
|
||||
return _handle_session_export(handler, parsed)
|
||||
|
||||
@@ -327,6 +331,61 @@ def handle_post(handler, parsed):
|
||||
s.save()
|
||||
return j(handler, {'ok': True, 'session': s.compact()})
|
||||
|
||||
# ── Session move to project (POST) ──
|
||||
if parsed.path == '/api/session/move':
|
||||
try: require(body, 'session_id')
|
||||
except ValueError as e: return bad(handler, str(e))
|
||||
try: s = get_session(body['session_id'])
|
||||
except KeyError: return bad(handler, 'Session not found', 404)
|
||||
s.project_id = body.get('project_id') or None
|
||||
s.save()
|
||||
return j(handler, {'ok': True, 'session': s.compact()})
|
||||
|
||||
# ── Project CRUD (POST) ──
|
||||
if parsed.path == '/api/projects/create':
|
||||
try: require(body, 'name')
|
||||
except ValueError as e: return bad(handler, str(e))
|
||||
projects = load_projects()
|
||||
proj = {'project_id': uuid.uuid4().hex[:12], 'name': body['name'], 'color': body.get('color'), 'created_at': time.time()}
|
||||
projects.append(proj)
|
||||
save_projects(projects)
|
||||
return j(handler, {'ok': True, 'project': proj})
|
||||
|
||||
if parsed.path == '/api/projects/rename':
|
||||
try: require(body, 'project_id', 'name')
|
||||
except ValueError as e: return bad(handler, str(e))
|
||||
projects = load_projects()
|
||||
proj = next((p for p in projects if p['project_id'] == body['project_id']), None)
|
||||
if not proj: return bad(handler, 'Project not found', 404)
|
||||
proj['name'] = body['name']
|
||||
if 'color' in body: proj['color'] = body['color']
|
||||
save_projects(projects)
|
||||
return j(handler, {'ok': True, 'project': proj})
|
||||
|
||||
if parsed.path == '/api/projects/delete':
|
||||
try: require(body, 'project_id')
|
||||
except ValueError as e: return bad(handler, str(e))
|
||||
projects = load_projects()
|
||||
proj = next((p for p in projects if p['project_id'] == body['project_id']), None)
|
||||
if not proj: return bad(handler, 'Project not found', 404)
|
||||
projects = [p for p in projects if p['project_id'] != body['project_id']]
|
||||
save_projects(projects)
|
||||
# Unassign all sessions that belonged to this project
|
||||
if SESSION_INDEX_FILE.exists():
|
||||
try:
|
||||
index = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
||||
for entry in index:
|
||||
if entry.get('project_id') == body['project_id']:
|
||||
try:
|
||||
s = get_session(entry['session_id'])
|
||||
s.project_id = None
|
||||
s.save()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return j(handler, {'ok': True})
|
||||
|
||||
# ── Session import from JSON (POST) ──
|
||||
if parsed.path == '/api/session/import':
|
||||
return _handle_session_import(handler, body)
|
||||
|
||||
Reference in New Issue
Block a user