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

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