feat: Sprint 15 — session projects, code copy button, tool card toggle
Session projects: named groups for organizing sessions. Project filter bar with chips between search and session list. Create/rename/delete projects, assign sessions via folder icon dropdown. Stored in projects.json, project_id on Session model. 5 new API endpoints. Code block copy button: every code block gets a Copy button in the language header (or top-right for plain blocks). Clipboard API with "Copied!" feedback. Tool card expand/collapse: messages with 2+ tool cards get an "Expand all / Collapse all" toggle above the card group. 13 new tests (237 total), all passing. No regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user