fix: project picker clipped, full-screen width bug, New Project shortcut

Five fixes to the Sprint 15 Move to Project picker:

1. CRITICAL: Picker was invisible (overflow:hidden clipping)
   Appended to document.body + positioned with fixed/getBoundingClientRect
   instead of inside .session-item (overflow:hidden). Flips above button
   when near bottom of viewport.

2. CRITICAL: Picker stretched full screen width
   position:fixed removed the containing block width constraint. Added
   max-width:220px; width:max-content to .project-picker.

3. UX: No way to create a project from the picker
   Added '+ New project': creates project and moves session in one click.

4. UX: Feature was undiscoverable
   Folder button shows persistently (blue, 60% opacity) when session
   has a project.

5. Minor: Event listener leak
   removeEventListener was missing from picker item onclick handlers.

Tests: 237 passed (7 pre-existing failures from unrelated logger bug).
This commit is contained in:
Nathan Esquenazi
2026-04-02 18:18:20 +00:00
parent ca06fd5533
commit e59eb8bb5b
2 changed files with 15 additions and 9 deletions

View File

@@ -287,7 +287,8 @@ function renderSessionListFromCache(){
trash.onclick=async(e)=>{e.stopPropagation();e.preventDefault();await deleteSession(s.session_id);}; trash.onclick=async(e)=>{e.stopPropagation();e.preventDefault();await deleteSession(s.session_id);};
// Project move button (folder icon) // Project move button (folder icon)
const move=document.createElement('button'); const move=document.createElement('button');
move.className='session-action-btn'+(s.project_id?' has-project':'');move.innerHTML='📂';move.title='Move to project'; move.className='session-action-btn'+(s.project_id?' has-project':'');
move.innerHTML='📂';move.title='Move to project';
move.onclick=async(e)=>{e.stopPropagation();e.preventDefault();_showProjectPicker(s,move);}; move.onclick=async(e)=>{e.stopPropagation();e.preventDefault();_showProjectPicker(s,move);};
// Project dot indicator // Project dot indicator
if(s.project_id){ if(s.project_id){
@@ -360,15 +361,13 @@ function _showProjectPicker(session, anchorEl){
document.querySelectorAll('.project-picker').forEach(p=>p.remove()); document.querySelectorAll('.project-picker').forEach(p=>p.remove());
const picker=document.createElement('div'); const picker=document.createElement('div');
picker.className='project-picker'; picker.className='project-picker';
// Close on outside click
const close=(e)=>{if(!picker.contains(e.target)&&e.target!==anchorEl){picker.remove();document.removeEventListener('click',close);}};
// "No project" option // "No project" option
const none=document.createElement('div'); const none=document.createElement('div');
none.className='project-picker-item'+(!session.project_id?' active':''); none.className='project-picker-item'+(!session.project_id?' active':'');
none.textContent='No project'; none.textContent='No project';
none.onclick=async()=>{ none.onclick=async()=>{
document.removeEventListener('click',close);
picker.remove(); picker.remove();
document.removeEventListener('click',close);
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})}); await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})});
session.project_id=null; session.project_id=null;
renderSessionListFromCache(); renderSessionListFromCache();
@@ -389,8 +388,8 @@ function _showProjectPicker(session, anchorEl){
name.textContent=p.name; name.textContent=p.name;
item.appendChild(name); item.appendChild(name);
item.onclick=async()=>{ item.onclick=async()=>{
document.removeEventListener('click',close);
picker.remove(); picker.remove();
document.removeEventListener('click',close);
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})}); 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; session.project_id=p.project_id;
renderSessionListFromCache(); renderSessionListFromCache();
@@ -398,19 +397,21 @@ function _showProjectPicker(session, anchorEl){
}; };
picker.appendChild(item); picker.appendChild(item);
} }
// "+ New project" item // "+ New project" shortcut at the bottom
const createItem=document.createElement('div'); const createItem=document.createElement('div');
createItem.className='project-picker-item project-picker-create'; createItem.className='project-picker-item project-picker-create';
createItem.textContent='+ New project'; createItem.textContent='+ New project';
createItem.onclick=async()=>{ createItem.onclick=async()=>{
picker.remove(); picker.remove();
document.removeEventListener('click',close); document.removeEventListener('click',close);
// Prompt for name inline
const name=prompt('Project name:'); const name=prompt('Project name:');
if(!name||!name.trim()) return; if(!name||!name.trim()) return;
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length]; const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color})}); const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color})});
if(res.project){ if(res.project){
_allProjects.push(res.project); _allProjects.push(res.project);
// Now move session into it
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:res.project.project_id})}); await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:res.project.project_id})});
session.project_id=res.project.project_id; session.project_id=res.project.project_id;
await renderSessionList(); await renderSessionList();
@@ -418,11 +419,13 @@ function _showProjectPicker(session, anchorEl){
} }
}; };
picker.appendChild(createItem); picker.appendChild(createItem);
// Position picker on document.body to avoid overflow:hidden clipping // Append to body and position using getBoundingClientRect so it isn't clipped
// by overflow:hidden on .session-item ancestors
document.body.appendChild(picker); document.body.appendChild(picker);
const rect=anchorEl.getBoundingClientRect(); const rect=anchorEl.getBoundingClientRect();
picker.style.position='fixed'; picker.style.position='fixed';
picker.style.zIndex='999'; picker.style.zIndex='999';
// Prefer opening below; flip above if too close to bottom of viewport
const spaceBelow=window.innerHeight-rect.bottom; const spaceBelow=window.innerHeight-rect.bottom;
if(spaceBelow<160&&rect.top>160){ if(spaceBelow<160&&rect.top>160){
picker.style.bottom=(window.innerHeight-rect.top+4)+'px'; picker.style.bottom=(window.innerHeight-rect.top+4)+'px';
@@ -431,10 +434,13 @@ function _showProjectPicker(session, anchorEl){
picker.style.top=(rect.bottom+4)+'px'; picker.style.top=(rect.bottom+4)+'px';
picker.style.bottom='auto'; picker.style.bottom='auto';
} }
const pickerW=160; // Align right edge of picker with right edge of button; keep within viewport
const pickerW=Math.min(220,Math.max(160,picker.scrollWidth||160));
let left=rect.right-pickerW; let left=rect.right-pickerW;
if(left<8) left=8; if(left<8) left=8;
picker.style.left=left+'px'; picker.style.left=left+'px';
// 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); setTimeout(()=>document.addEventListener('click',close),0);
} }

View File

@@ -540,7 +540,7 @@ body.resizing{user-select:none;cursor:col-resize;}
.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{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-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-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;max-width:220px;width:max-content;box-shadow:0 4px 16px rgba(0,0,0,.3);} .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:160px;max-width:220px;width:max-content;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{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:hover{background:rgba(255,255,255,.08);color:var(--text);}
.project-picker-item.active{color:var(--blue);} .project-picker-item.active{color:var(--blue);}