1019 lines
48 KiB
TypeScript
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 };
|