From 1a4793848e2eeb50519d81a477cf964ed9735f77 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Wed, 1 Apr 2026 23:55:21 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=2015=20=E2=80=94=20session=20pro?= =?UTF-8?q?jects,=20code=20copy=20button,=20tool=20card=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 18 ++++ ROADMAP.md | 7 +- SPRINTS.md | 125 +++++++++++----------- api/config.py | 1 + api/models.py | 24 ++++- api/routes.py | 59 +++++++++++ static/sessions.js | 184 ++++++++++++++++++++++++++++++-- static/style.css | 24 +++++ static/ui.js | 48 ++++++++- tests/test_sprint15.py | 234 +++++++++++++++++++++++++++++++++++++++++ 10 files changed, 650 insertions(+), 74 deletions(-) create mode 100644 tests/test_sprint15.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c08327a..f73802d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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* diff --git a/ROADMAP.md b/ROADMAP.md index 5e276b8..227feea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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: / --- @@ -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) diff --git a/SPRINTS.md b/SPRINTS.md index 8f2a65c..3887611 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -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)* diff --git a/api/config.py b/api/config.py index c593ee9..198ab3e 100644 --- a/api/config.py +++ b/api/config.py @@ -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: diff --git a/api/models.py b/api/models.py index a18591a..e3f8b05 100644 --- a/api/models.py +++ b/api/models.py @@ -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') diff --git a/api/routes.py b/api/routes.py index 0ddf44d..255aa6e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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) diff --git a/static/sessions.js b/static/sessions.js index 502fb96..ef5d315 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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='🗑';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='📂';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'); +} + diff --git a/static/style.css b/static/style.css index ee1d542..28bef02 100644 --- a/static/style.css +++ b/static/style.css @@ -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;} diff --git a/static/ui.js b/static/ui.js index 0d1ca38..7ea328e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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; diff --git a/tests/test_sprint15.py b/tests/test_sprint15.py new file mode 100644 index 0000000..1a22656 --- /dev/null +++ b/tests/test_sprint15.py @@ -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