Merge pull request #11 from nesquena/sprint-15-session-projects

Sprint 15: Session Projects + Code Copy + Tool Card Toggle
This commit is contained in:
Nathan Esquenazi
2026-04-02 00:12:26 -07:00
committed by GitHub
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 ## [v0.16.2] Model List Updates + base_url Passthrough
*April 1, 2026 | 247 tests* *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. > 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. > Everything you can do from the CLI terminal, you can do from this UI.
> >
> Last updated: Sprint 14 (March 30, 2026) > Last updated: Sprint 15 (April 1, 2026)
> Tests: 226/226 passing > Tests: 237 passing
> Source: <repo>/ > 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 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 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 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] Import session from JSON (Sprint 12)
- [x] Pin/star sessions to top of list (Sprint 12) - [x] Pin/star sessions to top of list (Sprint 12)
- [x] Duplicate session (Sprint 13) - [x] Duplicate session (Sprint 13)
- [x] Session projects / folders (Sprint 15)
### Workspace Management ### Workspace Management
- [x] Add workspace with path validation (must be existing directory) - [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 **Theme:** Polish the visual experience, close workspace file gaps, and
organize sessions properly. organize sessions properly.
@@ -169,60 +169,63 @@ organize sessions properly.
sessions hidden from sidebar by default. "Show N archived" toggle at top sessions hidden from sidebar by default. "Show N archived" toggle at top
of list. `POST /api/session/archive` endpoint. 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. **Tests:** ~12 new. Total: ~233.
**Hermes CLI parity impact:** Medium (file rename, folder create) **Hermes CLI parity impact:** Medium (file rename, folder create)
**Claude parity impact:** Medium (Mermaid, tags, archive) **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. **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. **Why now:** After 100+ sessions the sidebar is a flat chronological list.
Finding sessions from 2 weeks ago, or keeping a "MyProject" workspace separate Finding sessions from 2 weeks ago, or keeping work separated by project,
from personal work, requires the search box. This is the biggest remaining requires the search box. Session projects are the single biggest remaining
daily organizational gap vs. Claude's project folders. organizational gap vs. Claude's project folders.
### Track A: Bugs ### Track A: Bugs
- Session search content scan (depth=5) is slow on large session histories. - None.
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.
### Track B: Features ### Track B: Features
- **Session folders / projects:** A "Projects" section above the session list. - **Session projects:** Named groups for organizing sessions. A project
Each project is a named group. Sessions can be dragged into projects or filter bar (subtle chips) sits between the search input and the session
assigned via right-click. Stored in `projects.json`. Projects collapse/expand. list. Each project has a name and color. Click a chip to filter sessions
This is the single biggest Claude parity feature missing. to that project; "All" shows everything. Create projects inline (+
- ~~Pin sessions~~ (DONE Sprint 12) button), rename (double-click chip), delete (right-click). Assign
- ~~Import session from JSON~~ (DONE Sprint 12) sessions via folder icon button (hover-reveal) with a dropdown picker.
Projects stored in `projects.json`. Session model gains `project_id`
### Deferred to later sprints field (null = unassigned). Fully backward-compatible with existing
- Session tags / labels sessions. Endpoints: `GET /api/projects`, `POST /api/projects/create`,
- Archive sessions `POST /api/projects/rename`, `POST /api/projects/delete`,
- Rename file / Create folder (can be done through the agent) `POST /api/session/move`.
- Toolset control per session - **Code block copy button:** Every code block gets a "Copy" button.
- Virtual scroll for session list 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 ### Track C: Architecture
- Session index v2: extend `_index.json` to include `project_id` field. - `projects.json` flat file storage for project list (same pattern as
Rebuild on session save. Enables fast client-side filtering without disk reads. `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) **Hermes CLI parity impact:** Low (CLI has no session organization)
**Claude parity impact:** Very High (projects are a core Claude concept) **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. **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. **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. **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. **Theme:** Make this safe to leave running.
@@ -380,7 +383,7 @@ address.
## Feature Parity Summary ## Feature Parity Summary
### After Sprint 17 (Hermes CLI parity: complete) ### After Sprint 18 (Hermes CLI parity: complete)
| CLI Feature | Status | | CLI Feature | Status |
|-------------|--------| |-------------|--------|
@@ -395,16 +398,16 @@ address.
| Session history | Done (v0.3) | | Session history | Done (v0.3) |
| Workspace switching | Done (v0.7) | | Workspace switching | Done (v0.7) |
| Model selection | Done (v0.3) | | Model selection | Done (v0.3) |
| Multi-provider model support | Sprint 11 | | Multi-provider model support | Done (Sprint 11) |
| Toolset control | Sprint 12 | | Toolset control | Sprint 12 |
| Settings persistence | Sprint 12 | | Settings persistence | Done (Sprint 12) |
| Subagent visibility | Sprint 17 | | Subagent visibility | Sprint 18 |
| Background task monitor | Sprint 17 | | Background task monitor | Sprint 18 |
| Code execution (Jupyter) | Sprint 15 | | Code execution (Jupyter) | Sprint 16 |
| Cron completion alerts | Sprint 13 | | Cron completion alerts | Done (Sprint 13) |
| Virtual scroll (perf) | Sprint 13 | | Virtual scroll (perf) | Deferred |
### After Sprint 18 (Claude parity: ~90% complete) ### After Sprint 19 (Claude parity: ~90% complete)
| Claude Feature | Status | | Claude Feature | Status |
|----------------|--------| |----------------|--------|
@@ -416,19 +419,19 @@ address.
| Tool use visibility | Done (v0.11) | | Tool use visibility | Done (v0.11) |
| Edit/regenerate messages | Done (v0.10) | | Edit/regenerate messages | Done (v0.10) |
| Session management | Done (v0.6) | | Session management | Done (v0.6) |
| Artifacts (HTML/SVG preview) | Sprint 15 | | Artifacts (HTML/SVG preview) | Sprint 16 |
| Code execution inline | Sprint 15 | | Code execution inline | Sprint 16 |
| Mermaid diagrams | Sprint 15 | | Mermaid diagrams | Done (Sprint 14) |
| Projects / folders | Sprint 14 | | Projects / folders | Done (Sprint 15) |
| Pinned/starred sessions | Sprint 14 | | Pinned/starred sessions | Done (Sprint 12) |
| Reasoning display | Sprint 17 | | Reasoning display | Sprint 18 |
| Voice input | Sprint 16 | | Voice input | Sprint 17 |
| TTS playback | Sprint 16 | | TTS playback | Sprint 17 |
| Notifications | Sprint 13 | | Notifications | Done (Sprint 13) |
| Settings panel | Sprint 12 | | Settings panel | Done (Sprint 12) |
| Auth / login | Sprint 18 | | Auth / login | Sprint 19 |
| HTTPS | Sprint 18 | | HTTPS | Sprint 19 |
| Mobile layout | Sprint 18 | | Mobile layout | Done (v0.16.1) |
| Sharing / public URLs | Not planned (requires server infra) | | Sharing / public URLs | Not planned (requires server infra) |
| Claude-specific features | Not replicable (Projects AI, artifacts sync) | | Claude-specific features | Not replicable (Projects AI, artifacts sync) |
@@ -445,6 +448,6 @@ address.
--- ---
*Last updated: March 30, 2026* *Last updated: April 1, 2026*
*Current version: v0.13 | 201 tests* *Current version: v0.17 | 237 tests*
*Next sprint: Sprint 14 (visual polish + small QoL)* *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' SESSION_INDEX_FILE = SESSION_DIR / '_index.json'
SETTINGS_FILE = STATE_DIR / 'settings.json' SETTINGS_FILE = STATE_DIR / 'settings.json'
LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt' LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt'
PROJECTS_FILE = STATE_DIR / 'projects.json'
# ── Hermes agent directory discovery ───────────────────────────────────────── # ── Hermes agent directory discovery ─────────────────────────────────────────
def _discover_agent_dir() -> Path: def _discover_agent_dir() -> Path:

View File

@@ -10,7 +10,7 @@ from pathlib import Path
import api.config as _cfg import api.config as _cfg
from api.config import ( from api.config import (
SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX, 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 from api.workspace import get_last_workspace
@@ -34,8 +34,8 @@ def _write_session_index():
class Session: 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): 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.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 @property
def path(self): return SESSION_DIR / f'{self.session_id}.json' 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() 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' p = SESSION_DIR / f'{sid}.json'
if not p.exists(): return None if not p.exists(): return None
return cls(**json.loads(p.read_text(encoding='utf-8'))) 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): def get_session(sid):
with LOCK: with LOCK:
@@ -114,3 +114,19 @@ def title_from(messages, fallback='Untitled'):
if text: if text:
return text[:64] return text[:64]
return fallback 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 ( from api.models import (
Session, get_session, new_session, all_sessions, title_from, Session, get_session, new_session, all_sessions, title_from,
_write_session_index, SESSION_INDEX_FILE, _write_session_index, SESSION_INDEX_FILE,
load_projects, save_projects,
) )
from api.workspace import ( from api.workspace import (
load_workspaces, save_workspaces, get_last_workspace, set_last_workspace, load_workspaces, save_workspaces, get_last_workspace, set_last_workspace,
@@ -93,6 +94,9 @@ def handle_get(handler, parsed):
if parsed.path == '/api/sessions': if parsed.path == '/api/sessions':
return j(handler, {'sessions': all_sessions()}) return j(handler, {'sessions': all_sessions()})
if parsed.path == '/api/projects':
return j(handler, {'projects': load_projects()})
if parsed.path == '/api/session/export': if parsed.path == '/api/session/export':
return _handle_session_export(handler, parsed) return _handle_session_export(handler, parsed)
@@ -327,6 +331,61 @@ def handle_post(handler, parsed):
s.save() s.save()
return j(handler, {'ok': True, 'session': s.compact()}) 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) ── # ── Session import from JSON (POST) ──
if parsed.path == '/api/session/import': if parsed.path == '/api/session/import':
return _handle_session_import(handler, body) return _handle_session_import(handler, body)

View File

@@ -56,12 +56,18 @@ async function loadSession(sid){
let _allSessions = []; // cached for search filter let _allSessions = []; // cached for search filter
let _renamingSid = null; // session_id currently being renamed (blocks list re-renders) let _renamingSid = null; // session_id currently being renamed (blocks list re-renders)
let _showArchived = false; // toggle to show archived sessions 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(){ async function renderSessionList(){
try{ try{
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
const data=await api('/api/sessions'); const [sessData, projData] = await Promise.all([
_allSessions = data.sessions||[]; api('/api/sessions'),
api('/api/projects'),
]);
_allSessions = sessData.sessions||[];
_allProjects = projData.projects||[];
renderSessionListFromCache(); // no-ops if rename is in progress renderSessionListFromCache(); // no-ops if rename is in progress
}catch(e){console.warn('renderSessionList',e);} }catch(e){console.warn('renderSessionList',e);}
} }
@@ -94,10 +100,49 @@ function renderSessionListFromCache(){
// Merge content matches (deduped): content matches appended after title matches // Merge content matches (deduped): content matches appended after title matches
const titleIds=new Set(titleMatches.map(s=>s.session_id)); const titleIds=new Set(titleMatches.map(s=>s.session_id));
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches; 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 // Filter archived unless toggle is on
const sessions=_showArchived?allMatched:allMatched.filter(s=>!s.archived); const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
const archivedCount=allMatched.filter(s=>s.archived).length; const archivedCount=projectFiltered.filter(s=>s.archived).length;
const list=$('sessionList');list.innerHTML=''; 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 // Show/hide archived toggle if there are archived sessions
if(archivedCount>0){ if(archivedCount>0){
const toggle=document.createElement('div'); const toggle=document.createElement('div');
@@ -106,6 +151,13 @@ function renderSessionListFromCache(){
toggle.onclick=()=>{_showArchived=!_showArchived;renderSessionListFromCache();}; toggle.onclick=()=>{_showArchived=!_showArchived;renderSessionListFromCache();};
list.appendChild(toggle); 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 // Separate pinned from unpinned
const pinned=sessions.filter(s=>s.pinned); const pinned=sessions.filter(s=>s.pinned);
const unpinned=sessions.filter(s=>!s.pinned); const unpinned=sessions.filter(s=>!s.pinned);
@@ -233,7 +285,22 @@ function renderSessionListFromCache(){
const trash=document.createElement('button'); const trash=document.createElement('button');
trash.className='session-trash';trash.innerHTML='&#128465;';trash.title='Delete'; trash.className='session-trash';trash.innerHTML='&#128465;';trash.title='Delete';
trash.onclick=async(e)=>{e.stopPropagation();e.preventDefault();await deleteSession(s.session_id);}; 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). // 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, // This prevents loadSession from firing on the first click of a double-click,
@@ -241,7 +308,7 @@ function renderSessionListFromCache(){
let _clickTimer=null; let _clickTimer=null;
el.onclick=async(e)=>{ el.onclick=async(e)=>{
if(_renamingSid) return; // ignore while any rename is active 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); clearTimeout(_clickTimer);
_clickTimer=setTimeout(async()=>{ _clickTimer=setTimeout(async()=>{
_clickTimer=null; _clickTimer=null;
@@ -284,4 +351,109 @@ async function deleteSession(sid){
await renderSessionList(); 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{background:transparent;padding:8px 0;}
.mermaid-rendered svg{max-width:100%;height:auto;} .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;} .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(); const frag=document.createDocumentFragment();
for(const tc of cards){frag.appendChild(buildToolCard(tc));} 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); if(insertBefore) inner.insertBefore(frag,insertBefore);
else inner.appendChild(frag); else inner.appendChild(frag);
} }
} }
scrollToBottom(); scrollToBottom();
// Apply syntax highlighting after DOM is built // Apply syntax highlighting after DOM is built
requestAnimationFrame(()=>{highlightCode();renderMermaidBlocks();}); requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();});
// Refresh todo panel if it's currently open // Refresh todo panel if it's currently open
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){ if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
loadTodos(); loadTodos();
@@ -558,6 +574,36 @@ function highlightCode(container) {
Prism.highlightAllUnder(el); 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 _mermaidLoading=false;
let _mermaidReady=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