Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats
This commit is contained in:
@@ -146,6 +146,7 @@ let _showArchived = false; // toggle to show archived sessions
|
||||
let _allProjects = []; // cached project list
|
||||
let _activeProject = null; // project_id filter (null = show all)
|
||||
let _showAllProfiles = false; // false = filter to active profile only
|
||||
let _sessionsExpanded = false; // false = show only first 50 sessions
|
||||
let _sessionActionMenu = null;
|
||||
let _sessionActionAnchor = null;
|
||||
let _sessionActionSessionId = null;
|
||||
@@ -307,13 +308,14 @@ window.addEventListener('resize',()=>{
|
||||
|
||||
async function renderSessionList(){
|
||||
try{
|
||||
_sessionsExpanded = false; // reset expand state on fresh fetch
|
||||
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
||||
const [sessData, projData] = await Promise.all([
|
||||
api('/api/sessions'),
|
||||
api('/api/projects'),
|
||||
]);
|
||||
_allSessions = sessData.sessions||[];
|
||||
_allProjects = projData.projects||[];
|
||||
_allProjects = (projData.projects||[]).map(p=>({...p,project_id:p.project_id||p.id}));
|
||||
renderSessionListFromCache(); // no-ops if rename is in progress
|
||||
}catch(e){console.warn('renderSessionList',e);}
|
||||
}
|
||||
@@ -471,7 +473,11 @@ function renderSessionListFromCache(){
|
||||
// Show only sessions tagged to the active profile; 'All profiles' toggle overrides.
|
||||
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.is_cli_session||s.profile===S.activeProfile);
|
||||
// Filter by active project
|
||||
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
|
||||
// When a specific project is selected: show only that project's sessions
|
||||
// When "All" is selected: show all sessions (both with and without project_id)
|
||||
const projectFiltered=_activeProject
|
||||
? profileFiltered.filter(s=>s.project_id===_activeProject)
|
||||
: profileFiltered;
|
||||
// Filter archived unless toggle is on
|
||||
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
|
||||
const archivedCount=projectFiltered.filter(s=>s.archived).length;
|
||||
@@ -485,11 +491,30 @@ function renderSessionListFromCache(){
|
||||
allChip.className='project-chip'+(!_activeProject?' active':'');
|
||||
allChip.textContent='All';
|
||||
allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();};
|
||||
allChip.addEventListener('dragover',(e)=>{e.preventDefault();e.dataTransfer.dropEffect='move';});
|
||||
allChip.addEventListener('drop',async(e)=>{
|
||||
e.preventDefault();
|
||||
const sid=e.dataTransfer.getData('text/plain');
|
||||
if(!sid)return;
|
||||
try{
|
||||
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:sid,project_id:null})});
|
||||
const s=_allSessions.find(x=>x.session_id===sid);
|
||||
if(s)s.project_id=null;
|
||||
if(_activeProject===null)renderSessionListFromCache();
|
||||
else renderSessionList();
|
||||
}catch(_){}
|
||||
});
|
||||
bar.appendChild(allChip);
|
||||
// Project chips
|
||||
for(const p of _allProjects){
|
||||
const projId=p.project_id;
|
||||
const chip=document.createElement('span');
|
||||
chip.className='project-chip'+(p.project_id===_activeProject?' active':'');
|
||||
chip.className='project-chip'+(projId===_activeProject?' active':'');
|
||||
// Folder icon first
|
||||
const folderIcon=document.createElement('span');
|
||||
folderIcon.style.cssText='display:inline-flex;align-items:center;margin-right:3px;opacity:.75;';
|
||||
folderIcon.innerHTML=ICONS.folder;
|
||||
chip.appendChild(folderIcon);
|
||||
if(p.color){
|
||||
const dot=document.createElement('span');
|
||||
dot.className='color-dot';
|
||||
@@ -499,9 +524,22 @@ function renderSessionListFromCache(){
|
||||
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.onclick=()=>{_activeProject=projId;renderSessionListFromCache();};
|
||||
chip.ondblclick=(e)=>{e.stopPropagation();_startProjectRename({...p},chip);};
|
||||
chip.oncontextmenu=(e)=>{e.preventDefault();_confirmDeleteProject(p);};
|
||||
chip.addEventListener('dragover',(e)=>{e.preventDefault();e.dataTransfer.dropEffect='move';});
|
||||
chip.addEventListener('drop',async(e)=>{
|
||||
e.preventDefault();
|
||||
const sid=e.dataTransfer.getData('text/plain');
|
||||
if(!sid)return;
|
||||
try{
|
||||
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:sid,project_id:p.project_id})});
|
||||
const s=_allSessions.find(x=>x.session_id===sid);
|
||||
if(s)s.project_id=p.project_id;
|
||||
if(_activeProject===p.project_id)renderSessionListFromCache();
|
||||
else renderSessionList();
|
||||
}catch(_){}
|
||||
});
|
||||
bar.appendChild(chip);
|
||||
}
|
||||
// Create button
|
||||
@@ -547,17 +585,24 @@ function renderSessionListFromCache(){
|
||||
// Separate pinned from unpinned
|
||||
const pinned=orderedSessions.filter(s=>s.pinned);
|
||||
const unpinned=orderedSessions.filter(s=>!s.pinned);
|
||||
// Date grouping: Pinned / Today / Yesterday / This week / Last week / Older
|
||||
// Apply session limit to unpinned (pinned always shown in full)
|
||||
const SESSION_LIMIT = 50;
|
||||
const showAllSessions = _sessionsExpanded || unpinned.length <= SESSION_LIMIT;
|
||||
const limitedUnpinned = showAllSessions ? unpinned : unpinned.slice(0, SESSION_LIMIT);
|
||||
// Separate sessions without a project (unfiled) — they get their own section at the bottom
|
||||
const unfiledSessions=limitedUnpinned.filter(s=>!s.project_id);
|
||||
const filedSessions=limitedUnpinned.filter(s=>s.project_id);
|
||||
// Date grouping for filed sessions: Pinned / Today / Yesterday / This week / Last week / Older
|
||||
const now=Date.now();
|
||||
// Collapse state persisted in localStorage
|
||||
let _groupCollapsed={};
|
||||
try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){}
|
||||
const _saveCollapsed=()=>{try{localStorage.setItem('hermes-date-groups-collapsed',JSON.stringify(_groupCollapsed));}catch(e){}};
|
||||
// Group sessions by date
|
||||
// Group filed sessions by date
|
||||
const groups=[];
|
||||
let curLabel=null,curItems=[];
|
||||
if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true});
|
||||
for(const s of unpinned){
|
||||
for(const s of filedSessions){
|
||||
const ts=_sessionTimestampMs(s);
|
||||
const label=_sessionTimeBucketLabel(ts, now);
|
||||
if(label!==curLabel){
|
||||
@@ -593,20 +638,97 @@ function renderSessionListFromCache(){
|
||||
wrapper.appendChild(body);
|
||||
list.appendChild(wrapper);
|
||||
}
|
||||
// ── Unfiled sessions (no project) — shown as a collapsible "Unfiled" group at the bottom ──
|
||||
if(unfiledSessions.length>0){
|
||||
const unfiledOrdered=[...unfiledSessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
|
||||
const unfiledGroup={label:'\u{1F4C1} Unfiled',items:unfiledOrdered,isUnfiled:true};
|
||||
const wrapper=document.createElement('div');
|
||||
wrapper.className='session-date-group';
|
||||
const hdr=document.createElement('div');
|
||||
hdr.className='session-date-header'+(unfiledGroup.isUnfiled?' unfiled':'');
|
||||
const caret=document.createElement('span');
|
||||
caret.className='session-date-caret';
|
||||
caret.textContent='\u25B8';
|
||||
const label=document.createElement('span');
|
||||
label.textContent=unfiledGroup.label;
|
||||
hdr.appendChild(caret);hdr.appendChild(label);
|
||||
const body=document.createElement('div');
|
||||
body.className='session-date-body';
|
||||
if(_groupCollapsed[unfiledGroup.label]){body.style.display='none';caret.classList.add('collapsed');}
|
||||
hdr.onclick=()=>{
|
||||
const isCollapsed=body.style.display==='none';
|
||||
body.style.display=isCollapsed?'':'none';
|
||||
caret.classList.toggle('collapsed',!isCollapsed);
|
||||
_groupCollapsed[unfiledGroup.label]=!isCollapsed;
|
||||
_saveCollapsed();
|
||||
};
|
||||
wrapper.appendChild(hdr);
|
||||
for(const s of unfiledGroup.items){ body.appendChild(_renderOneSession(s)); }
|
||||
wrapper.appendChild(body);
|
||||
list.appendChild(wrapper);
|
||||
}
|
||||
// ── Show all sessions expander (when not expanded) ──
|
||||
if(!showAllSessions && unpinned.length > SESSION_LIMIT){
|
||||
const expander=document.createElement('div');
|
||||
expander.style.cssText='padding:10px 14px;color:var(--muted);cursor:pointer;text-align:center;font-size:12px;opacity:.8;';
|
||||
expander.textContent=`Show all ${unpinned.length} sessions`;
|
||||
expander.onclick=()=>{_sessionsExpanded=true;renderSessionListFromCache();};
|
||||
list.appendChild(expander);
|
||||
}
|
||||
// ── HTML decoder + garbage title guard ──
|
||||
function _decodeHtml(str){
|
||||
const txt=document.createElement('textarea');
|
||||
txt.innerHTML=str;
|
||||
return txt.value;
|
||||
}
|
||||
function _isGarbageTitle(t){
|
||||
return t.startsWith('[SYSTEM:')||t.startsWith('[Note:')||t.startsWith('<input')||t.startsWith('[Note: model was')||/<input|&lt;input/.test(t)||t.includes('model was just switched')||t.length<3;
|
||||
}
|
||||
// ── Render session items (extracted for group body use) ──
|
||||
// Note: declared after the groups loop but available via function hoisting.
|
||||
function _renderOneSession(s){
|
||||
const el=document.createElement('div');
|
||||
const isActive=S.session&&s.session_id===S.session.session_id;
|
||||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'');
|
||||
el.draggable=true;
|
||||
el.dataset.sessionId=s.session_id;
|
||||
// Drag & Drop
|
||||
el.addEventListener('dragstart',(e)=>{
|
||||
e.dataTransfer.effectAllowed='move';
|
||||
e.dataTransfer.setData('text/plain',s.session_id);
|
||||
el.classList.add('dragging');
|
||||
});
|
||||
el.addEventListener('dragend',()=>el.classList.remove('dragging'));
|
||||
el.addEventListener('dragover',(e)=>{
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect='move';
|
||||
const dragging=document.querySelector('.dragging');
|
||||
if(dragging&&dragging!==el){
|
||||
const rect=el.getBoundingClientRect();
|
||||
const mid=rect.top+rect.height/2;
|
||||
el.classList.toggle('drag-after',e.clientY>mid);
|
||||
}
|
||||
});
|
||||
el.addEventListener('drop',async(e)=>{
|
||||
e.preventDefault();
|
||||
el.classList.remove('drag-after');
|
||||
const draggedId=e.dataTransfer.getData('text/plain');
|
||||
if(draggedId===s.session_id)return;
|
||||
try{
|
||||
const allS=_allSessions.filter(ss=>!ss.archived);
|
||||
const draggedIdx=allS.findIndex(ss=>ss.session_id===draggedId);
|
||||
const targetIdx=allS.findIndex(ss=>ss.session_id===s.session_id);
|
||||
if(draggedIdx<0||targetIdx<0)return;
|
||||
const targetW=(allS[targetIdx].updated_at||Date.now())+1;
|
||||
await api('/api/session/reorder',{method:'POST',body:JSON.stringify({session_id:draggedId,weight:targetW})});
|
||||
renderSessionList();
|
||||
}catch(_){}
|
||||
});
|
||||
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
|
||||
const rawTitle=s.title||'Untitled';
|
||||
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
|
||||
let cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle;
|
||||
// Guard: system prompt content must never surface as a visible session title
|
||||
if(cleanTitle.startsWith('[SYSTEM:')){
|
||||
cleanTitle='Session';
|
||||
}
|
||||
const rawTitle=_decodeHtml(s.title||'Untitled');
|
||||
const tags=(rawTitle.match(/#[\\w-]+/g)||[]);
|
||||
let cleanTitle=tags.length?rawTitle.replace(/#[\\w-]+/g,'').trim():rawTitle;
|
||||
if(_isGarbageTitle(cleanTitle)){cleanTitle='Session';}
|
||||
const sessionText=document.createElement('div');
|
||||
sessionText.className='session-text';
|
||||
const titleRow=document.createElement('div');
|
||||
@@ -616,7 +738,12 @@ function renderSessionListFromCache(){
|
||||
title.textContent=cleanTitle||'Untitled';
|
||||
title.title='Double-click to rename';
|
||||
const tsMs=_sessionTimestampMs(s);
|
||||
const timeEl=document.createElement('span');
|
||||
timeEl.className='session-time';
|
||||
timeEl.textContent=_formatRelativeSessionTime(tsMs);
|
||||
timeEl.title=new Date(tsMs).toLocaleString();
|
||||
titleRow.appendChild(title);
|
||||
titleRow.appendChild(timeEl);
|
||||
sessionText.appendChild(titleRow);
|
||||
// Append tag chips after the title text
|
||||
for(const tag of tags){
|
||||
@@ -869,13 +996,19 @@ function _startProjectCreate(bar, addBtn){
|
||||
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})});
|
||||
if(!save||!inp.value.trim()){
|
||||
inp.replaceWith(addBtn);
|
||||
return;
|
||||
}
|
||||
const name=inp.value.trim();
|
||||
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
|
||||
inp.disabled=true;
|
||||
try{
|
||||
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name,color})});
|
||||
await renderSessionList();
|
||||
showToast('Project created');
|
||||
}else{
|
||||
inp.replaceWith(addBtn);
|
||||
}finally{
|
||||
inp.disabled=false;
|
||||
}
|
||||
};
|
||||
inp.onkeydown=(e)=>{
|
||||
@@ -886,7 +1019,7 @@ function _startProjectCreate(bar, addBtn){
|
||||
}
|
||||
if(e.key==='Escape'){e.preventDefault();finish(false);}
|
||||
};
|
||||
inp.onblur=()=>finish(false);
|
||||
inp.onblur=()=>{if(!inp.disabled)finish(false);};
|
||||
addBtn.replaceWith(inp);
|
||||
setTimeout(()=>inp.focus(),10);
|
||||
}
|
||||
@@ -896,12 +1029,18 @@ function _startProjectRename(proj, chip){
|
||||
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()})});
|
||||
if(!save||!inp.value.trim()||inp.value.trim()===proj.name){
|
||||
renderSessionListFromCache();
|
||||
return;
|
||||
}
|
||||
const name=inp.value.trim();
|
||||
inp.disabled=true;
|
||||
try{
|
||||
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name})});
|
||||
await renderSessionList();
|
||||
showToast('Project renamed');
|
||||
}else{
|
||||
renderSessionListFromCache();
|
||||
}finally{
|
||||
inp.disabled=false;
|
||||
}
|
||||
};
|
||||
inp.onkeydown=(e)=>{
|
||||
@@ -912,7 +1051,7 @@ function _startProjectRename(proj, chip){
|
||||
}
|
||||
if(e.key==='Escape'){e.preventDefault();finish(false);}
|
||||
};
|
||||
inp.onblur=()=>finish(false);
|
||||
inp.onblur=()=>{if(!inp.disabled)finish(false);};
|
||||
inp.onclick=(e)=>e.stopPropagation();
|
||||
chip.replaceWith(inp);
|
||||
setTimeout(()=>{inp.focus();inp.select();},10);
|
||||
|
||||
Reference in New Issue
Block a user