/* sessions.ts — Session management, list rendering, and project helpers */ /// // ── Session action icons (SVG, monochrome, inherit currentColor) ── const ICONS = { pin: '', unpin: '', folder: '', archive: '', unarchive:'', dup: '', trash: '', more: '', }; async function newSession(flash?: boolean, agentOverride?: string): Promise { updateQueueBadge(); S.toolCalls = []; clearLiveToolCards(); const inheritWs = S._profileDefaultWorkspace || (S.session ? S.session.workspace : null); S._profileDefaultWorkspace = null; const body: Record = { 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; 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 { 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; 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).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; const uRec = u as Record; _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 = {}; 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, extraClass = '' ): HTMLButtonElement { const opt = document.createElement('button'); opt.type = 'button'; opt.className = 'ws-opt session-action-opt' + (extraClass ? ` ${extraClass}` : ''); opt.innerHTML = `` + `${icon}` + `` + `${esc(label)}` + (meta ? `${esc(meta)}` : '') + `` + ``; 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 { 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 = new Set(); function _sessionTimestampMs(s: Session): number { return (s as Record).last_message_at as number || (s as Record).updated_at as number || (s as Record).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 => { _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 | null = null; el.onclick = async(e): Promise => { 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 { 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 => { 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 => { 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 => { 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 => { 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 => { 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 { 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 }[] = [ { 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 };