Merge pull request #11 from nesquena/sprint-15-session-projects
Sprint 15: Session Projects + Code Copy + Tool Card Toggle
This commit is contained in:
18
CHANGELOG.md
18
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*
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
125
SPRINTS.md
125
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)*
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;}
|
||||
|
||||
48
static/ui.js
48
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;
|
||||
|
||||
|
||||
234
tests/test_sprint15.py
Normal file
234
tests/test_sprint15.py
Normal 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
|
||||
Reference in New Issue
Block a user