| |
| import { send, on } from './ws.js'; |
| import { showContextMenu } from './ui.js'; |
| import { showShareModal } from './modals.js'; |
|
|
| export let sessions = []; |
| export let currentSessionId = null; |
|
|
| const sessionListeners = new Set(); |
| export function onSessionChange(fn) { |
| sessionListeners.add(fn); |
| return () => sessionListeners.delete(fn); |
| } |
|
|
| function notify(event, data) { |
| sessionListeners.forEach(fn => fn(event, data)); |
| } |
|
|
| |
|
|
| on('sessions:list', (msg) => { |
| sessions = msg.sessions || []; |
| renderSessions(); |
| }); |
|
|
| on('sessions:created', (msg) => { |
| const existing = sessions.findIndex(s => s.id === msg.session.id); |
| if (existing === -1) sessions.unshift(msg.session); |
| else sessions[existing] = msg.session; |
| renderSessions(); |
| notify('created', msg.session); |
| }); |
|
|
| on('sessions:deleted', (msg) => { |
| sessions = sessions.filter(s => s.id !== msg.sessionId); |
| if (currentSessionId === msg.sessionId) { |
| currentSessionId = sessions[0]?.id || null; |
| notify('switched', currentSessionId); |
| } |
| renderSessions(); |
| }); |
|
|
| on('sessions:deletedAll', () => { |
| sessions = []; |
| currentSessionId = null; |
| renderSessions(); |
| notify('switched', null); |
| }); |
|
|
| on('sessions:renamed', (msg) => { |
| const s = sessions.find(s => s.id === msg.sessionId); |
| if (s) s.name = msg.name; |
| renderSessions(); |
| }); |
|
|
| on('sessions:data', (msg) => { |
| const existing = sessions.findIndex(s => s.id === msg.session.id); |
| if (existing >= 0) sessions[existing] = msg.session; |
| notify('data', msg.session); |
| }); |
|
|
| on('auth:ok', (msg) => { |
| sessions = msg.sessions || []; |
| renderSessions(); |
| if (sessions.length > 0 && !currentSessionId) { |
| switchSession(sessions[0].id); |
| } |
| }); |
|
|
| on('auth:guestOk', (msg) => { |
| sessions = msg.sessions || []; |
| renderSessions(); |
| if (sessions.length === 0) { |
| createNewSession(); |
| } else { |
| switchSession(sessions[0].id); |
| } |
| }); |
|
|
| on('chat:done', (msg) => { |
| const s = sessions.find(s => s.id === msg.sessionId); |
| if (s) { |
| s.history = msg.history; |
| if (msg.name) s.name = msg.name; |
| sessions.sort((a, b) => { |
| const aTime = a.history?.at(-1)?.timestamp || a.created; |
| const bTime = b.history?.at(-1)?.timestamp || b.created; |
| return bTime - aTime; |
| }); |
| renderSessions(); |
| } |
| }); |
|
|
| on('sessions:imported', (msg) => { |
| sessions.unshift(msg.session); |
| renderSessions(); |
| switchSession(msg.session.id); |
| }); |
|
|
| |
|
|
| export function createNewSession() { |
| send({ type: 'sessions:create' }); |
| } |
|
|
| export function switchSession(id) { |
| currentSessionId = id; |
| renderSessions(); |
| send({ type: 'sessions:get', sessionId: id }); |
| notify('switched', id); |
| } |
|
|
| export function deleteSession(id) { |
| send({ type: 'sessions:delete', sessionId: id }); |
| } |
|
|
| export function deleteAllSessions() { |
| send({ type: 'sessions:deleteAll' }); |
| } |
|
|
| export function renameSession(id, name) { |
| send({ type: 'sessions:rename', sessionId: id, name }); |
| const s = sessions.find(s => s.id === id); |
| if (s) s.name = name; |
| renderSessions(); |
| } |
|
|
| export function requestSessions() { |
| send({ type: 'sessions:list' }); |
| } |
|
|
| export function getCurrentSession() { |
| return sessions.find(s => s.id === currentSessionId) || null; |
| } |
|
|
| |
|
|
| function renderSessions() { |
| const list = document.getElementById('session-list'); |
| if (!list) return; |
|
|
| if (sessions.length === 0) { |
| list.innerHTML = `<div style="padding:12px 10px;font-size:12px;color:var(--text-muted)">No chats yet</div>`; |
| return; |
| } |
|
|
| |
| const groups = groupByDate(sessions); |
| let html = ''; |
| for (const [label, group] of groups) { |
| html += `<div class="session-date-label">${escHtml(label)}</div>`; |
| for (const s of group) { |
| const active = s.id === currentSessionId ? ' active' : ''; |
| html += ` |
| <div class="session-item${active}" data-id="${escHtml(s.id)}"> |
| <span class="session-name" data-id="${escHtml(s.id)}">${escHtml(s.name || 'New Chat')}</span> |
| <button class="session-menu-btn" data-id="${escHtml(s.id)}" title="Options">···</button> |
| </div>`; |
| } |
| } |
| list.innerHTML = html; |
|
|
| |
| list.querySelectorAll('.session-item').forEach(el => { |
| el.addEventListener('click', (e) => { |
| if (e.target.closest('.session-menu-btn')) return; |
| switchSession(el.dataset.id); |
| }); |
| }); |
|
|
| |
| list.querySelectorAll('.session-menu-btn').forEach(btn => { |
| btn.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| openSessionMenu(e, btn.dataset.id); |
| }); |
| }); |
|
|
| |
| list.querySelectorAll('.session-name').forEach(el => { |
| el.addEventListener('dblclick', (e) => { |
| e.stopPropagation(); |
| startInlineRename(el); |
| }); |
| }); |
| } |
|
|
| function startInlineRename(el) { |
| const id = el.dataset.id; |
| const original = el.textContent; |
| el.setAttribute('contenteditable', 'true'); |
| el.focus(); |
| document.execCommand('selectAll', false, null); |
|
|
| const finish = () => { |
| el.removeAttribute('contenteditable'); |
| const name = el.textContent.trim(); |
| if (name && name !== original) renameSession(id, name); |
| else el.textContent = original; |
| }; |
|
|
| el.addEventListener('blur', finish, { once: true }); |
| el.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter') { e.preventDefault(); el.blur(); } |
| if (e.key === 'Escape') { el.textContent = original; el.blur(); } |
| }); |
| } |
|
|
| function openSessionMenu(e, id) { |
| const session = sessions.find(s => s.id === id); |
| const items = [ |
| { |
| label: 'Share', icon: '🔗', |
| onClick: () => showShareModal(id), |
| }, |
| { |
| label: 'Rename', icon: '✏️', |
| onClick: () => { |
| const nameEl = document.querySelector(`.session-name[data-id="${id}"]`); |
| if (nameEl) startInlineRename(nameEl); |
| }, |
| }, |
| { separator: true }, |
| { |
| label: 'Delete', icon: '🗑️', danger: true, |
| onClick: () => deleteSession(id), |
| }, |
| { |
| label: 'Delete All Chats', icon: '⚠️', danger: true, |
| onClick: () => { |
| if (confirm('Delete all chats? This cannot be undone.')) deleteAllSessions(); |
| }, |
| }, |
| ]; |
| showContextMenu(e.clientX, e.clientY, items); |
| } |
|
|
| function groupByDate(sessions) { |
| const now = new Date(); |
| const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); |
| const yesterday = today - 86400000; |
| const week = today - 6 * 86400000; |
|
|
| const groups = new Map([ |
| ['Today', []], |
| ['Yesterday', []], |
| ['This Week', []], |
| ['Older', []], |
| ]); |
|
|
| for (const s of sessions) { |
| const t = s.created || 0; |
| if (t >= today) groups.get('Today').push(s); |
| else if (t >= yesterday) groups.get('Yesterday').push(s); |
| else if (t >= week) groups.get('This Week').push(s); |
| else groups.get('Older').push(s); |
| } |
|
|
| return [...groups.entries()].filter(([, g]) => g.length > 0); |
| } |
|
|
| function escHtml(str) { |
| return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); |
| } |
|
|