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

@@ -5,6 +5,24 @@
---
## [v0.17] Sprint 15 -- Session Projects + Code Copy + Tool Card Toggle
*April 1, 2026 | 237 tests*
### Features
- **Session projects.** Named groups for organizing sessions. A project filter
bar (subtle chips) sits between the search input and the session list. Each
project has a name and color. Click a chip to filter; "All" shows everything.
Create inline (+), rename (double-click), delete (right-click). Assign sessions
via folder icon button with dropdown picker. Projects stored in `projects.json`.
Session model gains `project_id` field. 5 new API endpoints.
- **Code block copy button.** Every code block gets a "Copy" button in the
language header bar (or top-right for plain blocks). Click copies to clipboard,
shows "Copied!" for 1.5s.
- **Tool card expand/collapse.** When a message has 2+ tool cards, "Expand all /
Collapse all" toggle appears above the card group.
---
## [v0.16.2] Model List Updates + base_url Passthrough
*April 1, 2026 | 247 tests*

View File

@@ -3,8 +3,8 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: Sprint 14 (March 30, 2026)
> Tests: 226/226 passing
> Last updated: Sprint 15 (April 1, 2026)
> Tests: 237 passing
> Source: <repo>/
---
@@ -30,6 +30,8 @@
| Sprint 11 | Multi-provider models + streaming | Dynamic model dropdown (any Hermes provider), smooth scroll pinning, routes extracted to api/routes.py (server.py 704→76 lines) | 201 |
| Sprint 12 | Settings + reliability + session QoL | Settings panel (gear icon, settings.json), SSE auto-reconnect, pin sessions, import session from JSON | 211 |
| Sprint 13 | Alerts + polish | Cron completion alerts (polling + badge), background error banner, session duplicate, browser tab title | 221 |
| Sprint 14 | Visual polish + workspace ops | Mermaid diagrams, message timestamps, file rename, folder create, session tags, session archive | 233 |
| Sprint 15 | Session projects + code copy | Session projects/folders, code block copy button, tool card expand/collapse toggle | 237 |
---
@@ -103,6 +105,7 @@
- [x] Import session from JSON (Sprint 12)
- [x] Pin/star sessions to top of list (Sprint 12)
- [x] Duplicate session (Sprint 13)
- [x] Session projects / folders (Sprint 15)
### Workspace Management
- [x] Add workspace with path validation (must be existing directory)

View File

@@ -146,7 +146,7 @@ to daily friction.
---
## Sprint 14 -- Visual Polish + Workspace Ops + Session Organization
## Sprint 14 -- Visual Polish + Workspace Ops + Session Organization (COMPLETED)
**Theme:** Polish the visual experience, close workspace file gaps, and
organize sessions properly.
@@ -169,60 +169,63 @@ organize sessions properly.
sessions hidden from sidebar by default. "Show N archived" toggle at top
of list. `POST /api/session/archive` endpoint.
### Candidates for next sprints
- Workspace reorder (drag-and-drop)
- View skill linked files
- Voice input via Whisper
- Subagent delegation cards (enhanced tool card rendering)
**Tests:** ~12 new. Total: ~233.
**Hermes CLI parity impact:** Medium (file rename, folder create)
**Claude parity impact:** Medium (Mermaid, tags, archive)
---
## Sprint 15 -- Project Organization + Session Management
## Sprint 15 -- Session Projects + Code Copy + Tool Card Toggle (COMPLETED)
**Theme:** Organize work the way you think, not just chronologically.
Plus two quick UX wins for code and agentic workflows.
**Why now:** After 100+ sessions the sidebar is a flat chronological list.
Finding sessions from 2 weeks ago, or keeping a "MyProject" workspace separate
from personal work, requires the search box. This is the biggest remaining
daily organizational gap vs. Claude's project folders.
Finding sessions from 2 weeks ago, or keeping work separated by project,
requires the search box. Session projects are the single biggest remaining
organizational gap vs. Claude's project folders.
### Track A: Bugs
- Session search content scan (depth=5) is slow on large session histories.
Add server-side caching of search index.
- Date group headers ("Today / Yesterday / Earlier") use updated_at which can
be misleading for sessions touched by automated title-setting. Use created_at
for initial grouping, updated_at for sort order.
- None.
### Track B: Features
- **Session folders / projects:** A "Projects" section above the session list.
Each project is a named group. Sessions can be dragged into projects or
assigned via right-click. Stored in `projects.json`. Projects collapse/expand.
This is the single biggest Claude parity feature missing.
- ~~Pin sessions~~ (DONE Sprint 12)
- ~~Import session from JSON~~ (DONE Sprint 12)
### Deferred to later sprints
- Session tags / labels
- Archive sessions
- Rename file / Create folder (can be done through the agent)
- Toolset control per session
- Virtual scroll for session list
- **Session projects:** Named groups for organizing sessions. A project
filter bar (subtle chips) sits between the search input and the session
list. Each project has a name and color. Click a chip to filter sessions
to that project; "All" shows everything. Create projects inline (+
button), rename (double-click chip), delete (right-click). Assign
sessions via folder icon button (hover-reveal) with a dropdown picker.
Projects stored in `projects.json`. Session model gains `project_id`
field (null = unassigned). Fully backward-compatible with existing
sessions. Endpoints: `GET /api/projects`, `POST /api/projects/create`,
`POST /api/projects/rename`, `POST /api/projects/delete`,
`POST /api/session/move`.
- **Code block copy button:** Every code block gets a "Copy" button.
Positioned in the language header bar (or top-right corner for plain
code blocks). Click copies code to clipboard, shows "Copied!" for 1.5s.
- **Tool card expand/collapse:** When a message has 2+ tool cards, an
"Expand all / Collapse all" toggle appears above the card group.
Scoped per message group, not global.
### Track C: Architecture
- Session index v2: extend `_index.json` to include `project_id` field.
Rebuild on session save. Enables fast client-side filtering without disk reads.
- `projects.json` flat file storage for project list (same pattern as
`workspaces.json` and `settings.json`).
- `project_id` field on Session model with backward-compatible null default.
- `_index.json` includes `project_id` for fast client-side filtering.
**Tests:** ~16 new. Total: ~241.
**Tests:** 13 new. Total: ~237.
**Hermes CLI parity impact:** Low (CLI has no session organization)
**Claude parity impact:** Very High (projects are a core Claude concept)
### Candidates for next sprints
- Workspace reorder (drag-and-drop)
- View skill linked files
- Voice input via Whisper
- Subagent delegation cards (enhanced tool card rendering)
---
## Sprint 15 -- Artifacts + Code Execution
## Sprint 16 -- Artifacts + Code Execution
**Theme:** See outputs, not just text.
@@ -265,7 +268,7 @@ feels like. It also directly enables the Hermes "code execution cell" feature
---
## Sprint 16 -- Voice + Multimodal Input
## Sprint 17 -- Voice + Multimodal Input
**Theme:** Input beyond the keyboard.
@@ -303,7 +306,7 @@ file uploads, not clipboard screenshots into the conversation directly).
---
## Sprint 17 -- Subagent Visibility + Agentic Transparency
## Sprint 18 -- Subagent Visibility + Agentic Transparency
**Theme:** Watch Hermes think, not just respond.
@@ -343,7 +346,7 @@ what's happening. This is the last major "CLI feels better" gap for power users.
---
## Sprint 18 -- Auth, HTTPS, and Production Hardening
## Sprint 19 -- Auth, HTTPS, and Production Hardening
**Theme:** Make this safe to leave running.
@@ -380,7 +383,7 @@ address.
## Feature Parity Summary
### After Sprint 17 (Hermes CLI parity: complete)
### After Sprint 18 (Hermes CLI parity: complete)
| CLI Feature | Status |
|-------------|--------|
@@ -395,16 +398,16 @@ address.
| Session history | Done (v0.3) |
| Workspace switching | Done (v0.7) |
| Model selection | Done (v0.3) |
| Multi-provider model support | Sprint 11 |
| Multi-provider model support | Done (Sprint 11) |
| Toolset control | Sprint 12 |
| Settings persistence | Sprint 12 |
| Subagent visibility | Sprint 17 |
| Background task monitor | Sprint 17 |
| Code execution (Jupyter) | Sprint 15 |
| Cron completion alerts | Sprint 13 |
| Virtual scroll (perf) | Sprint 13 |
| Settings persistence | Done (Sprint 12) |
| Subagent visibility | Sprint 18 |
| Background task monitor | Sprint 18 |
| Code execution (Jupyter) | Sprint 16 |
| Cron completion alerts | Done (Sprint 13) |
| Virtual scroll (perf) | Deferred |
### After Sprint 18 (Claude parity: ~90% complete)
### After Sprint 19 (Claude parity: ~90% complete)
| Claude Feature | Status |
|----------------|--------|
@@ -416,19 +419,19 @@ address.
| Tool use visibility | Done (v0.11) |
| Edit/regenerate messages | Done (v0.10) |
| Session management | Done (v0.6) |
| Artifacts (HTML/SVG preview) | Sprint 15 |
| Code execution inline | Sprint 15 |
| Mermaid diagrams | Sprint 15 |
| Projects / folders | Sprint 14 |
| Pinned/starred sessions | Sprint 14 |
| Reasoning display | Sprint 17 |
| Voice input | Sprint 16 |
| TTS playback | Sprint 16 |
| Notifications | Sprint 13 |
| Settings panel | Sprint 12 |
| Auth / login | Sprint 18 |
| HTTPS | Sprint 18 |
| Mobile layout | Sprint 18 |
| Artifacts (HTML/SVG preview) | Sprint 16 |
| Code execution inline | Sprint 16 |
| Mermaid diagrams | Done (Sprint 14) |
| Projects / folders | Done (Sprint 15) |
| Pinned/starred sessions | Done (Sprint 12) |
| Reasoning display | Sprint 18 |
| Voice input | Sprint 17 |
| TTS playback | Sprint 17 |
| Notifications | Done (Sprint 13) |
| Settings panel | Done (Sprint 12) |
| Auth / login | Sprint 19 |
| HTTPS | Sprint 19 |
| Mobile layout | Done (v0.16.1) |
| Sharing / public URLs | Not planned (requires server infra) |
| Claude-specific features | Not replicable (Projects AI, artifacts sync) |
@@ -445,6 +448,6 @@ address.
---
*Last updated: March 30, 2026*
*Current version: v0.13 | 201 tests*
*Next sprint: Sprint 14 (visual polish + small QoL)*
*Last updated: April 1, 2026*
*Current version: v0.17 | 237 tests*
*Next sprint: Sprint 16 (Artifacts + Code Execution)*

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)

View File

@@ -56,12 +56,18 @@ async function loadSession(sid){
let _allSessions = []; // cached for search filter
let _renamingSid = null; // session_id currently being renamed (blocks list re-renders)
let _showArchived = false; // toggle to show archived sessions
let _allProjects = []; // cached project list
let _activeProject = null; // project_id filter (null = show all)
async function renderSessionList(){
try{
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
const data=await api('/api/sessions');
_allSessions = data.sessions||[];
const [sessData, projData] = await Promise.all([
api('/api/sessions'),
api('/api/projects'),
]);
_allSessions = sessData.sessions||[];
_allProjects = projData.projects||[];
renderSessionListFromCache(); // no-ops if rename is in progress
}catch(e){console.warn('renderSessionList',e);}
}
@@ -94,10 +100,49 @@ function renderSessionListFromCache(){
// Merge content matches (deduped): content matches appended after title matches
const titleIds=new Set(titleMatches.map(s=>s.session_id));
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
// Filter by active project
const projectFiltered=_activeProject?allMatched.filter(s=>s.project_id===_activeProject):allMatched;
// Filter archived unless toggle is on
const sessions=_showArchived?allMatched:allMatched.filter(s=>!s.archived);
const archivedCount=allMatched.filter(s=>s.archived).length;
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
const archivedCount=projectFiltered.filter(s=>s.archived).length;
const list=$('sessionList');list.innerHTML='';
// Project filter bar (only when projects exist)
if(_allProjects.length>0){
const bar=document.createElement('div');
bar.className='project-bar';
// "All" chip
const allChip=document.createElement('span');
allChip.className='project-chip'+(!_activeProject?' active':'');
allChip.textContent='All';
allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();};
bar.appendChild(allChip);
// Project chips
for(const p of _allProjects){
const chip=document.createElement('span');
chip.className='project-chip'+(p.project_id===_activeProject?' active':'');
if(p.color){
const dot=document.createElement('span');
dot.className='color-dot';
dot.style.background=p.color;
chip.appendChild(dot);
}
const nameSpan=document.createElement('span');
nameSpan.textContent=p.name;
chip.appendChild(nameSpan);
chip.onclick=()=>{_activeProject=p.project_id;renderSessionListFromCache();};
chip.ondblclick=(e)=>{e.stopPropagation();_startProjectRename(p,chip);};
chip.oncontextmenu=(e)=>{e.preventDefault();_confirmDeleteProject(p);};
bar.appendChild(chip);
}
// Create button
const addBtn=document.createElement('button');
addBtn.className='project-create-btn';
addBtn.textContent='+';
addBtn.title='New project';
addBtn.onclick=(e)=>{e.stopPropagation();_startProjectCreate(bar,addBtn);};
bar.appendChild(addBtn);
list.appendChild(bar);
}
// Show/hide archived toggle if there are archived sessions
if(archivedCount>0){
const toggle=document.createElement('div');
@@ -106,6 +151,13 @@ function renderSessionListFromCache(){
toggle.onclick=()=>{_showArchived=!_showArchived;renderSessionListFromCache();};
list.appendChild(toggle);
}
// Empty state for active project filter
if(_activeProject&&sessions.length===0){
const empty=document.createElement('div');
empty.style.cssText='padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;';
empty.textContent='No sessions in this project yet.';
list.appendChild(empty);
}
// Separate pinned from unpinned
const pinned=sessions.filter(s=>s.pinned);
const unpinned=sessions.filter(s=>!s.pinned);
@@ -233,7 +285,22 @@ function renderSessionListFromCache(){
const trash=document.createElement('button');
trash.className='session-trash';trash.innerHTML='&#128465;';trash.title='Delete';
trash.onclick=async(e)=>{e.stopPropagation();e.preventDefault();await deleteSession(s.session_id);};
el.appendChild(pin);el.appendChild(title);el.appendChild(archive);el.appendChild(dup);el.appendChild(trash);
// Project move button (folder icon)
const move=document.createElement('button');
move.className='session-action-btn';move.innerHTML='&#128194;';move.title='Move to project';
move.onclick=async(e)=>{e.stopPropagation();e.preventDefault();_showProjectPicker(s,move);};
// Project dot indicator
if(s.project_id){
const proj=_allProjects.find(p=>p.project_id===s.project_id);
if(proj){
const dot=document.createElement('span');
dot.className='session-project-dot';
dot.style.background=proj.color||'var(--blue)';
dot.title=proj.name;
title.appendChild(dot);
}
}
el.appendChild(pin);el.appendChild(title);el.appendChild(move);el.appendChild(archive);el.appendChild(dup);el.appendChild(trash);
// Use a click timer to distinguish single-click (navigate) from double-click (rename).
// This prevents loadSession from firing on the first click of a double-click,
@@ -241,7 +308,7 @@ function renderSessionListFromCache(){
let _clickTimer=null;
el.onclick=async(e)=>{
if(_renamingSid) return; // ignore while any rename is active
if([trash,dup,archive].some(b=>e.target===b||b.contains(e.target))) return;
if([trash,dup,archive,move].some(b=>e.target===b||b.contains(e.target))) return;
clearTimeout(_clickTimer);
_clickTimer=setTimeout(async()=>{
_clickTimer=null;
@@ -284,4 +351,109 @@ async function deleteSession(sid){
await renderSessionList();
}
// ── Project helpers ─────────────────────────────────────────────────────
const PROJECT_COLORS=['#7cb9ff','#f5c542','#e94560','#50c878','#c084fc','#fb923c','#67e8f9','#f472b6'];
function _showProjectPicker(session, anchorEl){
// Close any existing picker
document.querySelectorAll('.project-picker').forEach(p=>p.remove());
const picker=document.createElement('div');
picker.className='project-picker';
// "No project" option
const none=document.createElement('div');
none.className='project-picker-item'+(!session.project_id?' active':'');
none.textContent='No project';
none.onclick=async()=>{
picker.remove();
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})});
session.project_id=null;
renderSessionListFromCache();
showToast('Removed from project');
};
picker.appendChild(none);
// Project options
for(const p of _allProjects){
const item=document.createElement('div');
item.className='project-picker-item'+(session.project_id===p.project_id?' active':'');
if(p.color){
const dot=document.createElement('span');
dot.className='color-dot';
dot.style.cssText='width:6px;height:6px;border-radius:50%;background:'+p.color+';flex-shrink:0;';
item.appendChild(dot);
}
const name=document.createElement('span');
name.textContent=p.name;
item.appendChild(name);
item.onclick=async()=>{
picker.remove();
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})});
session.project_id=p.project_id;
renderSessionListFromCache();
showToast('Moved to '+p.name);
};
picker.appendChild(item);
}
// Position relative to anchor
anchorEl.style.position='relative';
anchorEl.appendChild(picker);
// Close on outside click
const close=(e)=>{if(!picker.contains(e.target)&&e.target!==anchorEl){picker.remove();document.removeEventListener('click',close);}};
setTimeout(()=>document.addEventListener('click',close),0);
}
function _startProjectCreate(bar, addBtn){
const inp=document.createElement('input');
inp.className='project-create-input';
inp.placeholder='Project name';
const finish=async(save)=>{
if(save&&inp.value.trim()){
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})});
await renderSessionList();
showToast('Project created');
}else{
inp.replaceWith(addBtn);
}
};
inp.onkeydown=(e)=>{
if(e.key==='Enter'){e.preventDefault();finish(true);}
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
addBtn.replaceWith(inp);
setTimeout(()=>inp.focus(),10);
}
function _startProjectRename(proj, chip){
const inp=document.createElement('input');
inp.className='project-create-input';
inp.value=proj.name;
const finish=async(save)=>{
if(save&&inp.value.trim()&&inp.value.trim()!==proj.name){
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})});
await renderSessionList();
showToast('Project renamed');
}else{
renderSessionListFromCache();
}
};
inp.onkeydown=(e)=>{
if(e.key==='Enter'){e.preventDefault();finish(true);}
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
inp.onclick=(e)=>e.stopPropagation();
chip.replaceWith(inp);
setTimeout(()=>{inp.focus();inp.select();},10);
}
async function _confirmDeleteProject(proj){
if(!confirm('Delete project "'+proj.name+'"? Sessions will be unassigned but not deleted.')){return;}
await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})});
if(_activeProject===proj.project_id) _activeProject=null;
await renderSessionList();
showToast('Project deleted');
}

View File

@@ -529,4 +529,28 @@ body.resizing{user-select:none;cursor:col-resize;}
.mermaid-rendered{background:transparent;padding:8px 0;}
.mermaid-rendered svg{max-width:100%;height:auto;}
/* ── Session projects ── */
.project-bar{display:flex;gap:4px;padding:4px 10px 8px;flex-wrap:wrap;align-items:center;flex-shrink:0;}
.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:rgba(255,255,255,.04);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;}
.project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);}
.project-chip.active{background:rgba(124,185,255,.12);color:var(--blue);border-color:rgba(124,185,255,.4);}
.project-chip .color-dot{width:6px;height:6px;border-radius:50%;display:inline-block;flex-shrink:0;}
.project-create-btn{font-size:10px;padding:3px 6px;border-radius:12px;cursor:pointer;border:1px dashed var(--border2);background:none;color:var(--muted);opacity:.6;transition:all .15s;}
.project-create-btn:hover{opacity:1;border-color:var(--blue);color:var(--blue);}
.project-create-input{font-size:10px;padding:3px 8px;border-radius:12px;border:1px solid rgba(124,185,255,.6);background:rgba(20,32,60,.9);color:var(--text);outline:none;width:100px;font-family:inherit;box-shadow:0 0 0 2px rgba(124,185,255,.15);}
.project-picker{position:absolute;right:0;top:100%;background:var(--sidebar);border:1px solid var(--border2);border-radius:8px;padding:4px;z-index:30;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.3);}
.project-picker-item{padding:5px 10px;font-size:11px;border-radius:6px;cursor:pointer;color:var(--muted);transition:all .1s;display:flex;align-items:center;gap:6px;}
.project-picker-item:hover{background:rgba(255,255,255,.08);color:var(--text);}
.project-picker-item.active{color:var(--blue);}
.session-project-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;display:inline-block;margin-left:4px;vertical-align:middle;}
/* ── Code copy button ── */
.code-copy-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;}
.code-copy-btn:hover{background:rgba(255,255,255,.12);color:var(--text);}
/* ── Tool card expand/collapse toggle ── */
.tool-cards-toggle{margin:4px 0 2px 40px;display:flex;gap:8px;}
.tool-cards-toggle button{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;opacity:.6;padding:0;}
.tool-cards-toggle button:hover{opacity:1;text-decoration:underline;}
.bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;}

View File

@@ -374,13 +374,29 @@ function renderMessages(){
}
const frag=document.createDocumentFragment();
for(const tc of cards){frag.appendChild(buildToolCard(tc));}
// Add expand/collapse toggle for groups with 2+ cards
if(cards.length>=2){
const toggle=document.createElement('div');
toggle.className='tool-cards-toggle';
// Collect card elements before they get moved to DOM
const cardEls=Array.from(frag.querySelectorAll('.tool-card'));
const expandBtn=document.createElement('button');
expandBtn.textContent='Expand all';
expandBtn.onclick=()=>cardEls.forEach(c=>c.classList.add('open'));
const collapseBtn=document.createElement('button');
collapseBtn.textContent='Collapse all';
collapseBtn.onclick=()=>cardEls.forEach(c=>c.classList.remove('open'));
toggle.appendChild(expandBtn);
toggle.appendChild(collapseBtn);
frag.insertBefore(toggle,frag.firstChild);
}
if(insertBefore) inner.insertBefore(frag,insertBefore);
else inner.appendChild(frag);
}
}
scrollToBottom();
// Apply syntax highlighting after DOM is built
requestAnimationFrame(()=>{highlightCode();renderMermaidBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();});
// Refresh todo panel if it's currently open
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
loadTodos();
@@ -558,6 +574,36 @@ function highlightCode(container) {
Prism.highlightAllUnder(el);
}
function addCopyButtons(container){
const el=container||$('msgInner');
if(!el) return;
el.querySelectorAll('pre > code').forEach(codeEl=>{
const pre=codeEl.parentElement;
if(pre.querySelector('.code-copy-btn')) return;
const btn=document.createElement('button');
btn.className='code-copy-btn';
btn.textContent='Copy';
btn.onclick=(e)=>{
e.stopPropagation();
navigator.clipboard.writeText(codeEl.textContent).then(()=>{
btn.textContent='Copied!';
setTimeout(()=>{btn.textContent='Copy';},1500);
});
};
const header=pre.previousElementSibling;
if(header&&header.classList.contains('pre-header')){
header.style.display='flex';
header.style.justifyContent='space-between';
header.style.alignItems='center';
header.appendChild(btn);
}else{
pre.style.position='relative';
btn.style.cssText='position:absolute;top:6px;right:6px;';
pre.appendChild(btn);
}
});
}
let _mermaidLoading=false;
let _mermaidReady=false;

234
tests/test_sprint15.py Normal file
View File

@@ -0,0 +1,234 @@
"""
Sprint 15 Tests: session projects (CRUD, move, backward compat).
"""
import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(BASE + path, data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def make_session(created_list):
d, _ = post("/api/session/new", {})
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, d["session"]
def make_project(created_list, name="Test Project", color=None):
body = {"name": name}
if color:
body["color"] = color
d, status = post("/api/projects/create", body)
assert status == 200
pid = d["project"]["project_id"]
created_list.append(pid)
return pid, d["project"]
def cleanup_projects(project_ids):
for pid in project_ids:
try:
post("/api/projects/delete", {"project_id": pid})
except Exception:
pass
# ── Project CRUD ─────────────────────────────────────────────────────────
def test_create_project():
"""Creating a project returns a valid project dict."""
pids = []
try:
pid, proj = make_project(pids, "My Project", "#7cb9ff")
assert pid and len(pid) == 12
assert proj["name"] == "My Project"
assert proj["color"] == "#7cb9ff"
assert "created_at" in proj
finally:
cleanup_projects(pids)
def test_list_projects_empty():
"""Listing projects when none exist returns empty list."""
d, status = get("/api/projects")
assert status == 200
assert isinstance(d["projects"], list)
def test_list_projects():
"""Listing projects returns created projects."""
pids = []
try:
make_project(pids, "Alpha")
make_project(pids, "Beta")
d, status = get("/api/projects")
assert status == 200
names = [p["name"] for p in d["projects"]]
assert "Alpha" in names
assert "Beta" in names
finally:
cleanup_projects(pids)
def test_rename_project():
"""Renaming a project updates its name."""
pids = []
try:
pid, _ = make_project(pids, "Old Name")
d, status = post("/api/projects/rename", {"project_id": pid, "name": "New Name"})
assert status == 200
assert d["project"]["name"] == "New Name"
# Verify via list
dl, _ = get("/api/projects")
names = [p["name"] for p in dl["projects"]]
assert "New Name" in names
assert "Old Name" not in names
finally:
cleanup_projects(pids)
def test_delete_project():
"""Deleting a project removes it from the list."""
pids = []
try:
pid, _ = make_project(pids, "Doomed")
d, status = post("/api/projects/delete", {"project_id": pid})
assert status == 200
assert d["ok"] is True
dl, _ = get("/api/projects")
assert all(p["project_id"] != pid for p in dl["projects"])
pids.clear() # already deleted
finally:
cleanup_projects(pids)
def test_delete_project_unassigns_sessions():
"""Deleting a project unassigns all sessions that belonged to it."""
pids = []
sids = []
try:
pid, _ = make_project(pids, "Temp Project")
sid, _ = make_session(sids)
# Assign session to project
post("/api/session/move", {"session_id": sid, "project_id": pid})
# Verify assigned
sd, _ = get(f"/api/session?session_id={sid}")
assert sd["session"].get("project_id") == pid
# Delete project
post("/api/projects/delete", {"project_id": pid})
pids.clear()
# Verify session is unassigned
sd2, _ = get(f"/api/session?session_id={sid}")
assert sd2["session"].get("project_id") is None
finally:
cleanup_projects(pids)
for s in sids:
post("/api/session/delete", {"session_id": s})
def test_create_project_requires_name():
"""Creating a project without a name returns 400."""
d, status = post("/api/projects/create", {})
assert status == 400
def test_delete_nonexistent_project():
"""Deleting a project that doesn't exist returns 404."""
d, status = post("/api/projects/delete", {"project_id": "nonexistent99"})
assert status == 404
# ── Session move ─────────────────────────────────────────────────────────
def test_session_move_to_project():
"""Moving a session to a project sets its project_id."""
pids = []
sids = []
try:
pid, _ = make_project(pids, "Work")
sid, _ = make_session(sids)
d, status = post("/api/session/move", {"session_id": sid, "project_id": pid})
assert status == 200
assert d["session"]["project_id"] == pid
finally:
cleanup_projects(pids)
for s in sids:
post("/api/session/delete", {"session_id": s})
def test_session_move_to_unassigned():
"""Moving a session to null project unassigns it."""
pids = []
sids = []
try:
pid, _ = make_project(pids, "Temp")
sid, _ = make_session(sids)
# Assign then unassign
post("/api/session/move", {"session_id": sid, "project_id": pid})
d, status = post("/api/session/move", {"session_id": sid, "project_id": None})
assert status == 200
assert d["session"]["project_id"] is None
finally:
cleanup_projects(pids)
for s in sids:
post("/api/session/delete", {"session_id": s})
def test_session_project_in_list():
"""Session list includes project_id for assigned sessions."""
pids = []
sids = []
try:
pid, _ = make_project(pids, "Listed")
sid, _ = make_session(sids)
# Give it a title so it shows in list (non-empty Untitled sessions are hidden)
post("/api/session/rename", {"session_id": sid, "title": "Project Test Session"})
post("/api/session/move", {"session_id": sid, "project_id": pid})
dl, _ = get("/api/sessions")
match = [s for s in dl["sessions"] if s["session_id"] == sid]
assert len(match) == 1
assert match[0]["project_id"] == pid
finally:
cleanup_projects(pids)
for s in sids:
post("/api/session/delete", {"session_id": s})
# ── Backward compat ──────────────────────────────────────────────────────
def test_compact_includes_project_id():
"""New session compact dict includes project_id as null."""
sids = []
try:
sid, sess = make_session(sids)
# Give it a title so it appears in the list
post("/api/session/rename", {"session_id": sid, "title": "Compat Test"})
dl, _ = get("/api/sessions")
match = [s for s in dl["sessions"] if s["session_id"] == sid]
assert len(match) == 1
assert "project_id" in match[0]
assert match[0]["project_id"] is None
finally:
for s in sids:
post("/api/session/delete", {"session_id": s})
def test_session_move_requires_session_id():
"""Moving without session_id returns 400."""
d, status = post("/api/session/move", {"project_id": "abc"})
assert status == 400