Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats

This commit is contained in:
Rose
2026-04-29 11:50:00 +02:00
parent c705fad626
commit 255914c9f1
43 changed files with 17948 additions and 6899 deletions

View File

@@ -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')||/&lt;input|&amp;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);