Files
webui-develop/static/sessions.ts

1019 lines
48 KiB
TypeScript

/* sessions.ts — Session management, list rendering, and project helpers */
/// <reference path="./global.d.ts" />
// ── Session action icons (SVG, monochrome, inherit currentColor) ──
const ICONS = {
pin: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><polygon points="8,1.5 9.8,5.8 14.5,6.2 11,9.4 12,14 8,11.5 4,14 5,9.4 1.5,6.2 6.2,5.8"/></svg>',
unpin: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><polygon points="8,2 9.8,6.2 14.2,6.2 10.7,9.2 12,13.8 8,11 4,13.8 5.3,9.2 1.8,6.2 6.2,6.2"/></svg>',
folder: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M2 4.5h4l1.5 1.5H14v7H2z"/></svg>',
archive: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><line x1="6" y1="8.5" x2="10" y2="8.5"/></svg>',
unarchive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><polyline points="6.5,7 8,5.5 9.5,7"/></svg>',
dup: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="4.5" y="4.5" width="8.5" height="8.5" rx="1.5"/><path d="M3 11.5V3h8.5"/></svg>',
trash: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
more: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
};
async function newSession(flash?: boolean, agentOverride?: string): Promise<void> {
updateQueueBadge();
S.toolCalls = [];
clearLiveToolCards();
const inheritWs = S._profileDefaultWorkspace || (S.session ? S.session.workspace : null);
S._profileDefaultWorkspace = null;
const body: Record<string, unknown> = { model: ($('modelSelect') as unknown as HTMLSelectElement | null)?.value || '', workspace: inheritWs };
const agentFromStorage = localStorage.getItem('hermes-webui-agent');
const agentToSend = agentOverride || agentFromStorage || undefined;
if (agentToSend) body.agent = agentToSend;
const data = await api('/api/session/new', { method: 'POST', body: JSON.stringify(body) }) as { session: Session };
S.session = data.session;
S.messages = (data.session.messages || []) as Message[];
S.lastUsage = { ...(data.session.last_usage || {}) } as Record<string, unknown>;
if (flash) S.session!._flash = true;
localStorage.setItem('hermes-webui-session', S.session.session_id);
S.busy = false;
S.activeStreamId = null;
updateSendBtn();
const _cb = $('btnCancel'); if (_cb) _cb.style.display = 'none';
setStatus('');
setComposerStatus('');
updateQueueBadge(S.session.session_id);
syncTopbar(); renderMessages(); loadDir('.');
}
async function loadSession(sid: string): Promise<void> {
if (S.session && S.busy) { stopApprovalPolling(); stopClarifyPolling(); cancelStream(); }
stopApprovalPolling(); hideApprovalCard();
if (typeof stopClarifyPolling === 'function') stopClarifyPolling();
if (typeof hideClarifyCard === 'function') hideClarifyCard();
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}`) as { session: Session };
S.session = data.session;
S.lastUsage = { ...(data.session.last_usage || {}) } as Record<string, unknown>;
localStorage.setItem('hermes-webui-session', S.session.session_id);
data.session.messages = ((data.session.messages || []) as Message[]).filter((m: Message) => m && m.role);
const hasMessageToolMetadata = ((data.session.messages || []) as Message[]).some((m: Message) => {
if (!m || m.role !== 'assistant') return false;
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
const hasTu = Array.isArray(m.content) && (m.content as unknown[]).some((p: unknown) => p && (p as { type?: string }).type === 'tool_use');
return hasTc || hasTu;
});
const activeStreamId = data.session.active_stream_id || null;
if (!INFLIGHT[sid] && activeStreamId && typeof loadInflightState === 'function') {
const stored = loadInflightState(sid, activeStreamId);
if (stored) {
INFLIGHT[sid] = {
messages: Array.isArray((stored as { messages?: unknown[] }).messages) && ((stored as { messages: unknown[] }).messages).length
? (stored as { messages: unknown[] }).messages as Message[]
: [...((data.session.messages || []) as Message[])],
uploaded: Array.isArray((stored as { uploaded?: unknown[] }).uploaded)
? (stored as { uploaded: unknown[] }).uploaded
: [...((data.session.pending_attachments || []) as unknown[])],
toolCalls: Array.isArray((stored as { toolCalls?: unknown[] }).toolCalls)
? (stored as { toolCalls: unknown[] }).toolCalls
: [],
reattach: true,
};
}
}
if (INFLIGHT[sid]) {
S.messages = INFLIGHT[sid].messages;
S.toolCalls = (INFLIGHT[sid].toolCalls || []) as unknown[];
S.busy = true;
syncTopbar(); renderMessages(); appendThinking(); loadDir('.');
clearLiveToolCards();
if (typeof placeLiveToolCardsHost === 'function') placeLiveToolCardsHost();
for (const tc of (S.toolCalls || [])) {
if (tc && (tc as { name?: string }).name) appendLiveToolCard(tc);
}
setBusy(true); setComposerStatus('');
startApprovalPolling(sid);
if (typeof startClarifyPolling === 'function') startClarifyPolling(sid);
S.activeStreamId = activeStreamId;
const _cb = $('btnCancel'); if (_cb && activeStreamId) _cb.style.display = 'inline-flex';
if (INFLIGHT[sid].reattach && activeStreamId && typeof attachLiveStream === 'function') {
INFLIGHT[sid].reattach = false;
attachLiveStream(sid, activeStreamId, (data.session.pending_attachments || []) as unknown[], { reconnecting: true });
}
} else {
updateQueueBadge(sid);
S.messages = (data.session.messages || []) as Message[];
const pendingMsg = typeof getPendingSessionMessage === 'function' ? getPendingSessionMessage(data.session) as Message | null : null;
if (pendingMsg) S.messages.push(pendingMsg);
if (!hasMessageToolMetadata && data.session.tool_calls && data.session.tool_calls.length) {
S.toolCalls = ((data.session.tool_calls || []) as unknown[]).map((tc: unknown) => ({ ...(tc as object), done: true }));
} else {
S.toolCalls = [];
}
clearLiveToolCards();
if (activeStreamId) {
S.busy = true;
S.activeStreamId = activeStreamId;
updateSendBtn();
const _cb = $('btnCancel'); if (_cb) _cb.style.display = 'inline-flex';
setStatus('');
setComposerStatus('');
syncTopbar(); renderMessages(); appendThinking(); loadDir('.');
updateQueueBadge(sid);
if((data.session as Record<string,unknown>).has_pending_approvals) startApprovalPolling(sid);
if (typeof startClarifyPolling === 'function') startClarifyPolling(sid);
if (typeof attachLiveStream === 'function') attachLiveStream(sid, activeStreamId, (data.session.pending_attachments || []) as unknown[], { reconnecting: true });
else if (typeof watchInflightSession === 'function') watchInflightSession(sid, activeStreamId);
} else {
S.busy = false;
S.activeStreamId = null;
updateSendBtn();
const _cb = $('btnCancel'); if (_cb) _cb.style.display = 'none';
setStatus('');
setComposerStatus('');
updateQueueBadge(sid);
syncTopbar(); renderMessages(); highlightCode!(); loadDir('.');
}
}
const _s = S.session;
if (_s && typeof _syncCtxIndicator === 'function') {
const u = S.lastUsage || {};
const sRec = _s as unknown as Record<string, unknown>;
const uRec = u as Record<string, unknown>;
_syncCtxIndicator({
input_tokens: (uRec.input_tokens ?? sRec.input_tokens ?? 0) as number,
output_tokens: (uRec.output_tokens ?? sRec.output_tokens ?? 0) as number,
estimated_cost: (uRec.estimated_cost ?? sRec.estimated_cost ?? 0) as number,
context_length: (uRec.context_length ?? sRec.context_length ?? 0) as number,
last_prompt_tokens:(uRec.last_prompt_tokens ?? sRec.last_prompt_tokens ?? 0) as number,
threshold_tokens: (uRec.threshold_tokens ?? sRec.threshold_tokens ?? 0) as number,
});
}
}
let _allSessions: Session[] = [];
let _renamingSid: string | null = null;
let _showArchived = false;
let _allProjects: Array<{ project_id: string; name: string; color?: string }> = [];
let _activeProject: string | null = null;
let _showAllProfiles = false;
let _sessionActionMenu: HTMLDivElement | null = null;
let _sessionActionAnchor: HTMLButtonElement | null = null;
let _sessionActionSessionId: string | null = null;
let _folderCollapsed: Record<string, boolean> = {};
let _activeTagFolder: string | null = null;
// ── Drag state ────────────────────────────────────────────────────────────────
let _dragSessionId: string | null = null;
let _dragOverFolderId: string | null = null;
function closeSessionActionMenu(): void {
if (_sessionActionMenu) { _sessionActionMenu.remove(); _sessionActionMenu = null; }
if (_sessionActionAnchor) {
_sessionActionAnchor.classList.remove('active');
const row = _sessionActionAnchor.closest('.session-item');
if (row) row.classList.remove('menu-open');
_sessionActionAnchor = null;
}
_sessionActionSessionId = null;
}
function _positionSessionActionMenu(anchorEl: HTMLElement): void {
if (!_sessionActionMenu || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const menuW = Math.min(280, Math.max(220, _sessionActionMenu.scrollWidth || 220));
let left = rect.right - menuW;
if (left < 8) left = 8;
if (left + menuW > window.innerWidth - 8) left = window.innerWidth - menuW - 8;
_sessionActionMenu.style.left = left + 'px';
_sessionActionMenu.style.top = '8px';
const menuH = _sessionActionMenu.offsetHeight || 0;
let top = rect.bottom + 6;
if (top + menuH > window.innerHeight - 8 && rect.top > menuH + 12) top = rect.top - menuH - 6;
if (top < 8) top = 8;
_sessionActionMenu.style.top = top + 'px';
}
function _buildSessionAction(
label: string, meta: string, icon: string,
onSelect: () => Promise<void>, extraClass = ''
): HTMLButtonElement {
const opt = document.createElement('button');
opt.type = 'button';
opt.className = 'ws-opt session-action-opt' + (extraClass ? ` ${extraClass}` : '');
opt.innerHTML =
`<span class="ws-opt-action">`
+ `<span class="ws-opt-icon">${icon}</span>`
+ `<span class="session-action-copy">`
+ `<span class="ws-opt-name">${esc(label)}</span>`
+ (meta ? `<span class="session-action-meta">${esc(meta)}</span>` : '')
+ `</span>`
+ `</span>`;
opt.onclick = async(e) => {
e.stopPropagation();
closeSessionActionMenu();
await onSelect();
};
return opt;
}
function _openSessionActionMenu(session: Session, anchorEl: HTMLButtonElement): void {
closeSessionActionMenu();
const menu = document.createElement('div');
menu.className = 'session-action-menu ws-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Session actions');
// Pin / Unpin
const newPinned = !session.pinned;
menu.appendChild(_buildSessionAction(
session.pinned ? 'Unpin' : 'Pin',
session.pinned ? 'Remove from pinned' : 'Pin to top',
session.pinned ? ICONS.unpin : ICONS.pin,
async() => {
try {
await api('/api/session/pin', { method: 'POST', body: JSON.stringify({ session_id: session.session_id, pinned: newPinned }) });
session.pinned = newPinned;
if (S.session && S.session.session_id === session.session_id) S.session.pinned = newPinned;
renderSessionList();
} catch (err: unknown) { showToast('Pin failed: ' + ((err as Error).message || String(err))); }
}
));
// Archive / Unarchive
menu.appendChild(_buildSessionAction(
session.archived ? 'Restore' : 'Archive',
session.archived ? 'Restore to inbox' : 'Remove from list',
session.archived ? ICONS.unarchive : ICONS.archive,
async() => {
try {
await api('/api/session/archive', { method: 'POST', body: JSON.stringify({ session_id: session.session_id, archived: !session.archived }) });
session.archived = !session.archived;
if (S.session && S.session.session_id === session.session_id) S.session.archived = session.archived;
await renderSessionList();
showToast(session.archived ? 'Session archived' : 'Session restored');
} catch (err: unknown) { showToast('Archive failed: ' + ((err as Error).message || String(err))); }
}
));
// Duplicate
menu.appendChild(_buildSessionAction(
'Duplicate', 'Create a copy', ICONS.dup,
async() => {
try {
const res = await api('/api/session/new', { method: 'POST', body: JSON.stringify({ clone_from: session.session_id }) }) as { session?: Session };
if (res.session) {
await api('/api/session/rename', { method: 'POST', body: JSON.stringify({ session_id: res.session.session_id, title: (session.title || 'Untitled') + ' (copy)' }) });
await loadSession(res.session.session_id);
await renderSessionList();
showToast('Session duplicated');
}
} catch (err: unknown) { showToast('Duplicate failed: ' + ((err as Error).message || String(err))); }
}
));
// Move to project
const currentProj = session.project_id ? _allProjects.find(p => p.project_id === session.project_id) : null;
menu.appendChild(_buildSessionAction(
'Move to project', currentProj ? `Currently: ${currentProj.name}` : 'Assign to project', ICONS.folder,
async() => { _showProjectPicker(session, anchorEl); }
));
// Delete
menu.appendChild(_buildSessionAction(
'Delete', 'Permanently remove', ICONS.trash,
async() => { await deleteSession(session.session_id); },
'danger'
));
_sessionActionMenu = menu;
_sessionActionAnchor = anchorEl;
_sessionActionSessionId = session.session_id;
anchorEl.classList.add('active');
anchorEl.closest('.session-item')?.classList.add('menu-open');
document.body.appendChild(menu);
if (_sessionActionMenu && _sessionActionAnchor) _positionSessionActionMenu(_sessionActionAnchor);
}
document.addEventListener('click', (e) => {
if (_sessionActionMenu && !_sessionActionMenu.contains(e.target as Node)) closeSessionActionMenu();
if (_sessionActionMenu && _sessionActionAnchor) _positionSessionActionMenu(_sessionActionAnchor);
});
async function renderSessionList(): Promise<void> {
try {
if (!($('sessionSearch') as HTMLInputElement | null)?.value?.trim()) _contentSearchResults = [];
const [sessData, projData] = await Promise.all([
api('/api/sessions') as Promise<{ sessions?: Session[] }>,
api('/api/projects') as Promise<{ projects?: Array<{ project_id: string; name: string; color?: string }> }>,
]);
_allSessions = sessData.sessions || [];
_allProjects = (projData.projects || []).map((p: any) => ({ ...p, project_id: p.project_id || p.id }));
renderSessionListFromCache();
} catch (e: unknown) { console.warn('renderSessionList', e); }
}
// ── Gateway session SSE (real-time sync for agent sessions) ──
let _gatewaySSE: EventSource | null = null;
let _gatewaySSEConnected = false;
function startGatewaySSE(): void {
if (_gatewaySSE) return;
_gatewaySSE = new EventSource('api/sessions/gateway/stream');
_gatewaySSE.addEventListener('sessions_changed', (ev) => {
try {
const data = JSON.parse(ev.data) as { sessions?: Array<{ session_id: string }> };
if (data.sessions) {
renderSessionList();
if (S.session && !S.busy && S.session.is_cli_session) {
const changedIds = new Set((data.sessions || []).map(s => s.session_id));
if (changedIds.has(S.session.session_id)) {
loadSession(S.session.session_id);
}
}
}
} catch { /* ignore */ }
});
_gatewaySSE.onerror = () => {
_gatewaySSE?.close();
_gatewaySSE = null;
_gatewaySSEConnected = false;
setTimeout(startGatewaySSE, 5000);
};
_gatewaySSE.onopen = () => { _gatewaySSEConnected = true; };
}
function stopGatewaySSE(): void {
_gatewaySSE?.close();
_gatewaySSE = null;
_gatewaySSEConnected = false;
}
let _contentSearchResults: Session[] = [];
function filterSessions(): void {
renderSessionListFromCache();
const q = ($('sessionSearch') as HTMLInputElement | null)?.value?.trim() || '';
clearTimeout(_searchDebounceTimer ?? undefined);
if (!q) { _contentSearchResults = []; return; }
_searchDebounceTimer = setTimeout(async () => {
const depth = 5;
const q2 = ($('sessionSearch') as HTMLInputElement | null)?.value?.trim() || '';
if (!q2) return;
try {
const data = await api(`/api/sessions/search?q=${encodeURIComponent(q2)}&content=1&depth=${depth}`) as { sessions?: Session[] };
_contentSearchResults = ((data.sessions || []) as Session[]).filter(s => (s as { match_type?: string }).match_type === 'content' && !titleIds.has(s.session_id));
renderSessionListFromCache();
} catch { /* ignore */ }
}, 300) as unknown as number;
}
let _searchDebounceTimer: number | null = null;
let titleIds: Set<string> = new Set();
function _sessionTimestampMs(s: Session): number {
return (s as Record<string, unknown>).last_message_at as number
|| (s as Record<string, unknown>).updated_at as number
|| (s as Record<string, unknown>).created_at as number
|| Date.now();
}
function _localDayOrdinal(timestampMs: number): number {
const date = new Date(timestampMs);
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);
}
function _sessionCalendarBoundaries(nowMs: number): { startOfToday: number; startOfYesterday: number; startOfWeek: number; startOfLastWeek: number } {
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startOfYesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const startOfWeek = new Date(startOfToday);
startOfWeek.setDate(startOfWeek.getDate() - ((startOfWeek.getDay() + 6) % 7));
const startOfLastWeek = new Date(startOfWeek);
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
return {
startOfToday: startOfToday.getTime(),
startOfYesterday: startOfYesterday.getTime(),
startOfWeek: startOfWeek.getTime(),
startOfLastWeek: startOfLastWeek.getTime(),
};
}
function _sessionTimeBucketLabel(ts: number, now: number): string {
if (!ts) return 'Older';
const { startOfToday, startOfYesterday, startOfWeek, startOfLastWeek } = _sessionCalendarBoundaries(now);
if (ts >= startOfToday) return 'Today';
if (ts >= startOfYesterday) return 'Yesterday';
if (ts >= startOfWeek) return 'This week';
if (ts >= startOfLastWeek) return 'Last week';
return 'Older';
}
function _sessionTimestampLabel(s: Session): string {
return _formatTs(_sessionTimestampMs(s));
}
function _formatTs(ts: number): string {
const d = new Date(ts);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86400000);
const targetDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
if (targetDay >= today) {
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
if (targetDay >= yesterday) {
return 'Yesterday';
}
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function _renderOneSession(s: Session): HTMLElement {
const el = document.createElement('div');
el.className = 'session-item' + (s.session_id === S.session?.session_id ? ' active' : '');
if (s.archived) el.classList.add('archived');
if (s.pinned) el.classList.add('pinned');
if (s.is_cli_session) el.classList.add('cli-session');
el.setAttribute('data-session-id', s.session_id);
el.setAttribute('draggable', 'true');
el.setAttribute('role', 'button');
el.setAttribute('tabindex', '0');
// ── Drag handlers ──────────────────────────────────────────────────────
el.addEventListener('dragstart', (e) => {
_dragSessionId = s.session_id;
el.classList.add('dragging');
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', s.session_id);
});
el.addEventListener('dragend', () => {
_dragSessionId = null;
_dragOverFolderId = null;
el.classList.remove('dragging');
document.querySelectorAll('.project-folder.drag-over, .tag-folder.drag-over').forEach(f => f.classList.remove('drag-over'));
});
el.addEventListener('dragover', (e) => {
if (!_dragSessionId || _dragSessionId === s.session_id) return;
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
});
el.addEventListener('drop', async(e) => {
if (!_dragSessionId || _dragSessionId === s.session_id) return;
e.preventDefault();
// Dropping on a session item = move to that session's project (if any)
const targetSession = s;
if (targetSession.project_id) {
await api('/api/session/move', { method: 'POST', body: JSON.stringify({ session_id: _dragSessionId, project_id: targetSession.project_id }) });
showToast('Moved to ' + (_allProjects.find(p => p.project_id === targetSession.project_id)?.name || 'project'));
} else {
await api('/api/session/move', { method: 'POST', body: JSON.stringify({ session_id: _dragSessionId, project_id: null }) });
showToast('Removed from project');
}
_dragSessionId = null;
_dragOverFolderId = null;
await renderSessionList();
});
const sessionText = document.createElement('div');
sessionText.className = 'session-text';
const title = document.createElement('div');
title.className = 'session-title';
const titleInner = document.createElement('span');
titleInner.className = 'session-title-text';
titleInner.textContent = s.title || 'Untitled';
title.appendChild(titleInner);
const meta = document.createElement('span');
meta.className = 'session-meta';
meta.textContent = _sessionTimestampLabel(s);
title.appendChild(meta);
const startRename = () => {
if (_renamingSid) return;
_renamingSid = s.session_id;
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'file-rename-input session-rename-input';
inp.value = s.title || 'Untitled';
inp.oncompositionstart = () => { (inp as any)._composing = true; };
inp.oncompositionend = () => { (inp as any)._composing = false; };
const finish = async(save: boolean): Promise<void> => {
_renamingSid = null;
if (save && inp.value.trim() && inp.value.trim() !== (s.title || 'Untitled')) {
const newTitle = inp.value.trim();
s.title = newTitle;
if (S.session && S.session.session_id === s.session_id) { S.session.title = newTitle; syncTopbar(); }
try { await api('/api/session/rename', { method: 'POST', body: JSON.stringify({ session_id: s.session_id, title: newTitle }) }); }
catch (err: unknown) { setStatus('Rename failed: ' + ((err as Error).message || String(err))); }
}
inp.replaceWith(title);
setTimeout(() => { if (_renamingSid === null) renderSessionListFromCache(); }, 50);
};
inp.onkeydown = e2 => {
if (e2.key === 'Enter') { if (e2.isComposing) return; e2.preventDefault(); e2.stopPropagation(); finish(true); }
if (e2.key === 'Escape') { e2.preventDefault(); e2.stopPropagation(); finish(false); }
};
inp.onblur = () => { if (_renamingSid === s.session_id) finish(false); };
title.replaceWith(inp);
setTimeout(() => { inp.focus(); inp.select(); }, 10);
};
if (s.pinned) {
const pinInd = document.createElement('span');
pinInd.className = 'session-pin-indicator';
pinInd.innerHTML = ICONS.pin;
el.appendChild(pinInd);
}
if (s.project_id) {
const proj = _allProjects.find(p => p.project_id === s.project_id);
if (proj) {
const folderIcon = document.createElement('span');
folderIcon.innerHTML = ICONS.folder;
folderIcon.className = 'session-project-folder';
folderIcon.title = proj.name;
title.appendChild(folderIcon);
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(sessionText);
sessionText.appendChild(title);
const actions = document.createElement('div');
actions.className = 'session-actions';
const menuBtn = document.createElement('button');
menuBtn.type = 'button';
menuBtn.className = 'session-actions-trigger';
menuBtn.title = 'Conversation actions';
menuBtn.setAttribute('aria-haspopup', 'menu');
menuBtn.setAttribute('aria-label', 'Conversation actions');
menuBtn.innerHTML = ICONS.more;
menuBtn.onclick = (e) => { e.stopPropagation(); e.preventDefault(); _openSessionActionMenu(s, menuBtn); };
actions.appendChild(menuBtn);
el.appendChild(actions);
let _clickTimer: ReturnType<typeof setTimeout> | null = null;
el.onclick = async(e): Promise<void> => {
if (_renamingSid) return;
if (actions.contains(e.target as Node)) return;
clearTimeout(_clickTimer ?? undefined);
_clickTimer = setTimeout(async() => {
_clickTimer = null;
if (_renamingSid) return;
if (s.is_cli_session) {
try { await api('/api/session/import_cli', { method: 'POST', body: JSON.stringify({ session_id: s.session_id }) }); }
catch { /* import failed -- fall through to read-only view */ }
}
await loadSession(s.session_id); renderSessionListFromCache();
if (typeof closeMobileSidebar === 'function') closeMobileSidebar();
}, 300);
};
el.ondblclick = (e): void => {
e.stopPropagation(); e.preventDefault();
clearTimeout(_clickTimer ?? undefined); _clickTimer = null;
startRename();
};
return el;
}
async function deleteSession(sid: string): Promise<void> {
const ok = await showConfirmDialog({
message: 'Delete this conversation?',
confirmLabel: t('delete_title'),
danger: true
});
if (!ok) return;
try {
await api('/api/session/delete', { method: 'POST', body: JSON.stringify({ session_id: sid }) });
} catch (err: unknown) { setStatus(`Delete failed: ${(err as Error).message || String(err)}`); return; }
if (typeof INFLIGHT !== 'undefined' && sid in INFLIGHT) delete INFLIGHT[sid];
if (typeof clearInflightState === 'function') clearInflightState(sid);
if (typeof clearInflight === 'function') clearInflight();
if (S.session && S.session.session_id === sid) {
S.session = null; S.messages = []; S.entries = [];
localStorage.removeItem('hermes-webui-session');
const remaining = await api('/api/sessions') as { sessions?: Session[] };
if (remaining.sessions && remaining.sessions.length) {
await loadSession(remaining.sessions[0].session_id);
} else {
const topbarTitle = $('topbarTitle');
const topbarMeta = $('topbarMeta');
const msgInner = $('msgInner');
const emptyState = $('emptyState');
const fileTree = $('fileTree');
if (topbarTitle) topbarTitle.textContent = window._botName || 'Hermes';
if (topbarMeta) topbarMeta.textContent = 'Start a new conversation';
if (msgInner) msgInner.innerHTML = '';
if (emptyState) emptyState.style.display = '';
if (fileTree) fileTree.innerHTML = '';
}
}
showToast('Conversation deleted');
await renderSessionList();
}
// ── Project helpers ────────────────────────────────────────────────────────────
const PROJECT_COLORS = ['#7cb9ff', '#f5c542', '#e94560', '#50c878', '#c084fc', '#fb923c', '#67e8f9', '#f472b6'];
function _showProjectPicker(session: Session, anchorEl: HTMLElement): void {
document.querySelectorAll('.project-picker').forEach(p => (p as any).remove());
const picker = document.createElement('div');
picker.className = 'project-picker';
const none = document.createElement('div');
none.className = 'project-picker-item' + (!session.project_id ? ' active' : '');
none.textContent = 'No project';
none.onclick = async(): Promise<void> => {
picker.remove();
document.removeEventListener('click', close as EventListener);
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);
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(): Promise<void> => {
picker.remove();
document.removeEventListener('click', close as EventListener);
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);
}
const createItem = document.createElement('div');
createItem.className = 'project-picker-item project-picker-create';
createItem.textContent = '+ New project';
createItem.onclick = async(): Promise<void> => {
picker.remove();
document.removeEventListener('click', close as EventListener);
const name = await showPromptDialog({ message: t('project_name_prompt'), confirmLabel: t('create'), placeholder: 'Project name' });
if (!name || !(name as string).trim()) return;
const color = PROJECT_COLORS[_allProjects.length % PROJECT_COLORS.length];
const res = await api('/api/projects/create', { method: 'POST', body: JSON.stringify({ name: (name as string).trim(), color }) }) as { project?: { project_id: string; name: string } };
if (res.project) {
_allProjects.push(res.project);
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;
await renderSessionList();
showToast('Created "' + res.project.name + '" and moved session');
}
};
picker.appendChild(createItem);
document.body.appendChild(picker);
const rect = anchorEl.getBoundingClientRect();
picker.style.position = 'fixed';
picker.style.zIndex = '999';
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceBelow < 160 && rect.top > 160) {
picker.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
picker.style.top = 'auto';
} else {
picker.style.top = (rect.bottom + 4) + 'px';
picker.style.bottom = 'auto';
}
const pickerW = Math.min(220, Math.max(160, picker.scrollWidth || 160));
let left = rect.right - pickerW;
if (left < 8) left = 8;
picker.style.left = left + 'px';
const close = (e: MouseEvent): void => { if (!picker.contains(e.target as Node) && e.target !== anchorEl) { picker.remove(); document.removeEventListener('click', close as EventListener); } };
setTimeout(() => document.addEventListener('click', close as EventListener), 0);
}
function _startProjectCreate(bar: HTMLElement, addBtn: HTMLButtonElement): void {
const inp = document.createElement('input');
inp.className = 'project-create-input';
inp.placeholder = 'Project name';
const finish = async(save: boolean): Promise<void> => {
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): void => {
if (e.key === 'Enter') { if (e.isComposing) return; 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: { project_id: string; name: string }, chip: HTMLElement): void {
const inp = document.createElement('input');
inp.className = 'project-create-input';
inp.value = proj.name;
const finish = async(save: boolean): Promise<void> => {
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): void => {
if (e.key === 'Enter') { if (e.isComposing) return; e.preventDefault(); finish(true); }
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
};
inp.onblur = (): void => { if ((inp as HTMLInputElement).disabled) return; finish(false); };
inp.onclick = (e) => e.stopPropagation();
chip.replaceWith(inp);
setTimeout(() => { inp.focus(); inp.select(); }, 10);
}
async function _confirmDeleteProject(proj: { project_id: string; name: string }): Promise<void> {
const ok = await showConfirmDialog({ message: 'Delete project "' + proj.name + '"? Sessions will be unassigned but not deleted.', confirmLabel: t('delete_title'), danger: true });
if (!ok) 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');
}
// ── Render session list from cache ─────────────────────────────────────────────
function renderSessionListFromCache(): void {
closeSessionActionMenu();
const q = (($('sessionSearch') as HTMLInputElement | null)?.value || '').toLowerCase();
const titleMatches = q ? _allSessions.filter(s => (s.title || 'Untitled').toLowerCase().includes(q)) : _allSessions;
titleIds = new Set(titleMatches.map(s => s.session_id));
const allMatched = q ? [...titleMatches, ..._contentSearchResults.filter(s => !titleIds.has(s.session_id))] : titleMatches;
const profileFiltered = _showAllProfiles ? allMatched : allMatched.filter(s => s.is_cli_session || s.profile === S.activeProfile);
const projectFiltered = _activeProject
? profileFiltered.filter(s => s.project_id === _activeProject)
: profileFiltered;
const sessions = _showArchived ? projectFiltered : projectFiltered.filter(s => !s.archived);
const archivedCount = projectFiltered.filter(s => s.archived).length;
const list = $('sessionList') as HTMLElement | null; if (!list) return;
list.innerHTML = '';
// ── Load folder collapse state ─────────────────────────────────────────────
try { _folderCollapsed = JSON.parse(localStorage.getItem('hermes-folders-collapsed') || '{}'); } catch { }
const _saveFolderCollapsed = () => { try { localStorage.setItem('hermes-folders-collapsed', JSON.stringify(_folderCollapsed)); } catch { } };
// ── Time-based session grouping (replaces tag-based) ──────────────────────────
// All sessions visible: pinned first, then by time bucket (no project filter hiding sessions)
const sessionsNoProject = profileFiltered.filter(s => !s.project_id);
const pinnedSessions = sessionsNoProject.filter(s => s.pinned).sort((a, b) => _sessionTimestampMs(b) - _sessionTimestampMs(a));
const unpinnedSessions = sessionsNoProject.filter(s => !s.pinned).sort((a, b) => _sessionTimestampMs(b) - _sessionTimestampMs(a));
// Time buckets
const nowMs = Date.now();
const { startOfToday, startOfYesterday, startOfWeek, startOfLastWeek } = _sessionCalendarBoundaries(nowMs);
const buckets: { label: string; ids: Set<string> }[] = [
{ label: 'Today', ids: new Set(unpinnedSessions.filter(s => _sessionTimestampMs(s) >= startOfToday).map(s => s.session_id)) },
{ label: 'Yesterday', ids: new Set(unpinnedSessions.filter(s => _sessionTimestampMs(s) >= startOfYesterday && _sessionTimestampMs(s) < startOfToday).map(s => s.session_id)) },
{ label: 'This week', ids: new Set(unpinnedSessions.filter(s => _sessionTimestampMs(s) >= startOfWeek && _sessionTimestampMs(s) < startOfYesterday).map(s => s.session_id)) },
{ label: 'Last week', ids: new Set(unpinnedSessions.filter(s => _sessionTimestampMs(s) >= startOfLastWeek && _sessionTimestampMs(s) < startOfWeek).map(s => s.session_id)) },
{ label: 'Older', ids: new Set(unpinnedSessions.filter(s => _sessionTimestampMs(s) < startOfLastWeek).map(s => s.session_id)) },
];
// Pinned section
if (pinnedSessions.length > 0) {
const pinnedSection = document.createElement('div');
pinnedSection.className = 'project-folders-section';
const folder = document.createElement('div');
folder.className = 'project-folder';
const hdr = document.createElement('div');
hdr.className = 'project-folder-header';
const caret = document.createElement('span');
caret.className = 'project-folder-caret';
caret.textContent = '\u25B8';
const dot = document.createElement('span');
dot.className = 'project-folder-dot';
dot.style.background = '#fbbf24';
const label = document.createElement('span');
label.className = 'project-folder-label';
label.textContent = 'Pinned';
const count = document.createElement('span');
count.className = 'project-folder-count';
count.textContent = String(pinnedSessions.length);
hdr.appendChild(caret); hdr.appendChild(dot); hdr.appendChild(label); hdr.appendChild(count);
const body = document.createElement('div');
body.className = 'tag-folder-body';
for (const s of pinnedSessions) body.appendChild(_renderOneSession(s));
folder.appendChild(hdr);
folder.appendChild(body);
pinnedSection.appendChild(folder);
list.appendChild(pinnedSection);
}
// Time bucket sections
for (const bucket of buckets) {
const bucketSessions = unpinnedSessions.filter(s => bucket.ids.has(s.session_id));
if (bucketSessions.length === 0) continue;
const isCollapsed = _folderCollapsed['time:' + bucket.label] || false;
const section = document.createElement('div');
section.className = 'project-folders-section';
const folder = document.createElement('div');
folder.className = 'project-folder';
const hdr = document.createElement('div');
hdr.className = 'project-folder-header';
const caret = document.createElement('span');
caret.className = 'project-folder-caret' + (isCollapsed ? ' collapsed' : '');
caret.textContent = '\u25B8';
const dot = document.createElement('span');
dot.className = 'project-folder-dot';
dot.style.background = bucket.label === 'Today' ? '#4ade80' : bucket.label === 'Yesterday' ? '#60a5fa' : '#a78bfa';
const label = document.createElement('span');
label.className = 'project-folder-label';
label.textContent = bucket.label;
const count = document.createElement('span');
count.className = 'project-folder-count';
count.textContent = String(bucketSessions.length);
hdr.appendChild(caret); hdr.appendChild(dot); hdr.appendChild(label); hdr.appendChild(count);
hdr.onclick = () => {
const collapsed = body.style.display === 'none';
body.style.display = collapsed ? '' : 'none';
caret.classList.toggle('collapsed', !collapsed);
_folderCollapsed['time:' + bucket.label] = !collapsed;
_saveFolderCollapsed();
};
const body = document.createElement('div');
body.className = 'tag-folder-body';
if (isCollapsed) body.style.display = 'none';
for (const s of bucketSessions) body.appendChild(_renderOneSession(s));
folder.appendChild(hdr);
folder.appendChild(body);
section.appendChild(folder);
list.appendChild(section);
}
// ── Project folders section (sessions WITH project_id) ─────────────────────────
const sessionsWithProject = profileFiltered.filter(s => s.project_id);
if (sessionsWithProject.length > 0 && _allProjects.length > 0) {
const projSection = document.createElement('div');
projSection.className = 'project-folders-section';
for (const p of _allProjects) {
const projSessions = sessionsWithProject.filter(s => s.project_id === p.project_id);
if (projSessions.length === 0) continue;
const isCollapsed = _folderCollapsed['proj:' + p.project_id] || false;
const folder = document.createElement('div');
folder.className = 'project-folder' + (p.project_id === _activeProject ? ' active' : '');
folder.setAttribute('data-project-id', p.project_id);
const hdr = document.createElement('div');
hdr.className = 'project-folder-header';
const caret = document.createElement('span');
caret.className = 'project-folder-caret' + (isCollapsed ? ' collapsed' : '');
caret.textContent = '\u25B8';
const dot = document.createElement('span');
dot.className = 'project-folder-dot';
dot.style.background = p.color || 'var(--blue)';
const label = document.createElement('span');
label.className = 'project-folder-label';
label.textContent = p.name;
const count = document.createElement('span');
count.className = 'project-folder-count';
count.textContent = String(projSessions.length);
hdr.appendChild(caret); hdr.appendChild(dot); hdr.appendChild(label); hdr.appendChild(count);
const body = document.createElement('div');
body.className = 'tag-folder-body';
if (isCollapsed) body.style.display = 'none';
if (p.project_id === _activeProject) {
const pinned = projSessions.filter(s => s.pinned).sort((a, b) => _sessionTimestampMs(b) - _sessionTimestampMs(a));
const unpinned = projSessions.filter(s => !s.pinned).sort((a, b) => _sessionTimestampMs(b) - _sessionTimestampMs(a));
for (const s of pinned) body.appendChild(_renderOneSession(s));
for (const s of unpinned) body.appendChild(_renderOneSession(s));
}
hdr.onclick = () => {
if (p.project_id === _activeProject) {
_activeProject = null;
} else {
_activeProject = p.project_id;
}
renderSessionListFromCache();
};
folder.appendChild(hdr);
folder.appendChild(body);
folder.addEventListener('dragover', (e) => {
if (!_dragSessionId) return;
e.preventDefault();
folder.classList.add('drag-over');
});
folder.addEventListener('dragleave', () => folder.classList.remove('drag-over'));
folder.addEventListener('drop', async(e) => {
if (!_dragSessionId) return;
e.preventDefault();
folder.classList.remove('drag-over');
await api('/api/session/move', { method: 'POST', body: JSON.stringify({ session_id: _dragSessionId, project_id: p.project_id }) });
showToast('Moved to ' + p.name);
_dragSessionId = null;
await renderSessionList();
});
projSection.appendChild(folder);
}
list.appendChild(projSection);
}
// ── New Project button ────────────────────────────────────────────────────────
if (_allProjects.length >= 0) {
const addSection = document.createElement('div');
addSection.className = 'project-folders-section';
const folder = document.createElement('div');
folder.className = 'project-folder project-folder-add';
const hdr = document.createElement('div');
hdr.className = 'project-folder-header project-folder-add-header';
const caret = document.createElement('span');
caret.style.cssText = 'opacity:0.4;';
const dot = document.createElement('span');
dot.style.cssText = 'width:8px;height:8px;border-radius:50%;border:1.5px dashed rgba(255,255,255,.3);display:inline-block;';
const label = document.createElement('span');
label.className = 'project-folder-label';
label.style.cssText = 'opacity:0.5;';
label.textContent = 'New Project';
hdr.appendChild(caret); hdr.appendChild(dot); hdr.appendChild(label);
hdr.onclick = (e) => { e.stopPropagation(); _startProjectCreate(list, hdr as any); };
folder.appendChild(hdr);
addSection.appendChild(folder);
list.appendChild(addSection);
}
// ── Profile toggles ──────────────────────────────────────────────────────────
const otherProfileCount = allMatched.filter(s => s.profile && s.profile !== S.activeProfile).length;
if (otherProfileCount > 0 && !_showAllProfiles) {
const pfToggle = document.createElement('div');
pfToggle.style.cssText = 'font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
pfToggle.textContent = 'Show ' + otherProfileCount + ' from other profiles';
pfToggle.onclick = () => { _showAllProfiles = true; renderSessionListFromCache(); };
list.appendChild(pfToggle);
} else if (_showAllProfiles && otherProfileCount > 0) {
const pfToggle = document.createElement('div');
pfToggle.style.cssText = 'font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
pfToggle.textContent = 'Show active profile only';
pfToggle.onclick = () => { _showAllProfiles = false; renderSessionListFromCache(); };
list.appendChild(pfToggle);
}
// ── Empty state ───────────────────────────────────────────────────────────────
if (sessions.length === 0 && !_activeProject) {
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 yet.';
list.appendChild(empty);
}
}
// _syncCtxIndicator is used by sessions.ts; declare it here since it's called from sessions.ts
declare function _syncCtxIndicator(usage: {
input_tokens?: number; output_tokens?: number; estimated_cost?: number;
context_length?: number; last_prompt_tokens?: number; threshold_tokens?: number;
}): void;
// ── Export to global scope (boot.ts expects these as globals) ─────────────────
(window as any).ICONS = ICONS;
(window as any).newSession = newSession;
(window as any).loadSession = loadSession;
(window as any).renderSessionList = renderSessionList;
(window as any).deleteSession = deleteSession;
(window as any).startGatewaySSE = startGatewaySSE;
(window as any).stopGatewaySSE = stopGatewaySSE;
(window as any).filterSessions = filterSessions;
(window as any).closeSessionActionMenu = closeSessionActionMenu;
export { ICONS, newSession, loadSession, renderSessionList, deleteSession, startGatewaySSE, stopGatewaySSE, filterSessions, closeSessionActionMenu };