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:
Nathan Esquenazi
2026-04-01 23:55:21 -07:00
parent 8ed206657c
commit 1a4793848e
10 changed files with 650 additions and 74 deletions

View File

@@ -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:

View File

@@ -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')

View File

@@ -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)