feat: persist active session across page navigation with inactivity TTL

Session ID is stored in localStorage keyed to user+persona. On page load
it's silently restored if within 30 min of last activity. Timestamp
updates on every sent message. New session / delete session clears the
stored ID so the TTL logic stays consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-26 23:38:04 -04:00
parent 4f09823afe
commit 92350f7a7b

View File

@@ -42,6 +42,31 @@
let currentHistory = []; // mirrors backend session [{role, content}, ...] let currentHistory = []; // mirrors backend session [{role, content}, ...]
let talkThinkingDiv = null; // pending "thinking…" bubble for live Talk updates let talkThinkingDiv = null; // pending "thinking…" bubble for live Talk updates
// ── Session persistence ───────────────────────────────────────
// Survives page navigation (help, settings, etc.) within the same browser.
// Expires after SESSION_TTL_MS of inactivity.
const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
const _sid_key = `cx_sid_${CORTEX_USER}_${CORTEX_PERSONA}`;
const _sid_ts_key = `cx_sid_ts_${CORTEX_USER}_${CORTEX_PERSONA}`;
function persist_session() {
if (!sessionId) return;
localStorage.setItem(_sid_key, sessionId);
localStorage.setItem(_sid_ts_key, String(Date.now()));
}
function clear_stored_session() {
localStorage.removeItem(_sid_key);
localStorage.removeItem(_sid_ts_key);
}
function get_stored_session() {
const id = localStorage.getItem(_sid_key);
const ts = parseInt(localStorage.getItem(_sid_ts_key) || '0', 10);
if (!id || Date.now() - ts > SESSION_TTL_MS) return null;
return id;
}
// ── Enter toggle ───────────────────────────────────────────── // ── Enter toggle ─────────────────────────────────────────────
// Default: Ctrl+Enter sends. Stored in localStorage. // Default: Ctrl+Enter sends. Stored in localStorage.
let ctrlEnterMode = localStorage.getItem('ctrlEnterSend') !== 'false'; let ctrlEnterMode = localStorage.getItem('ctrlEnterSend') !== 'false';
@@ -314,6 +339,7 @@
const newItem = makeItem('new', '+ New session', ''); const newItem = makeItem('new', '+ New session', '');
newItem.addEventListener('click', () => { newItem.addEventListener('click', () => {
sessionId = null; sessionId = null;
clear_stored_session();
currentHistory = []; currentHistory = [];
messagesEl.innerHTML = ''; messagesEl.innerHTML = '';
sessionEl.textContent = ''; sessionEl.textContent = '';
@@ -392,6 +418,7 @@
await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'DELETE' }); await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'DELETE' });
if (sessionId === s.session_id) { if (sessionId === s.session_id) {
sessionId = null; sessionId = null;
clear_stored_session();
currentHistory = []; currentHistory = [];
messagesEl.innerHTML = ''; messagesEl.innerHTML = '';
sessionEl.textContent = ''; sessionEl.textContent = '';
@@ -425,10 +452,11 @@
return item; return item;
} }
async function resumeSession(id) { async function resumeSession(id, silent = false) {
talkThinkingDiv = null; talkThinkingDiv = null;
if (id && id.startsWith('nct_')) sessionsBtn.classList.remove('talk-badge'); if (id && id.startsWith('nct_')) sessionsBtn.classList.remove('talk-badge');
const res = await fetch(`/history/${id}?${_fileParams}`); const res = await fetch(`/history/${id}?${_fileParams}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const data = await res.json();
messagesEl.innerHTML = ''; messagesEl.innerHTML = '';
@@ -444,10 +472,11 @@
attachHistoryControls(msgDiv, i); attachHistoryControls(msgDiv, i);
} }
addMessage('system', `Resumed session ${id}`); if (!silent) addMessage('system', `Resumed session ${id}`);
scrollToBottom(); scrollToBottom();
sessionsPanel.classList.remove('open'); sessionsPanel.classList.remove('open');
inputEl.focus(); inputEl.focus();
persist_session();
} }
function timeAgo(iso) { function timeAgo(iso) {
@@ -793,6 +822,7 @@
if (data.type === 'response') { if (data.type === 'response') {
sessionId = data.session_id; sessionId = data.session_id;
sessionEl.textContent = `session: ${sessionId}`; sessionEl.textContent = `session: ${sessionId}`;
persist_session();
thinkingDiv.className = 'message assistant'; thinkingDiv.className = 'message assistant';
setMessageText(thinkingDiv, 'assistant', data.response); setMessageText(thinkingDiv, 'assistant', data.response);
const assistHistIdx = currentHistory.length; const assistHistIdx = currentHistory.length;
@@ -893,6 +923,7 @@
if (job.session_id) { if (job.session_id) {
sessionId = job.session_id; sessionId = job.session_id;
sessionEl.textContent = `session: ${sessionId}`; sessionEl.textContent = `session: ${sessionId}`;
persist_session();
} }
const userHistIdx = currentHistory.length - 1; // pushed before fetch const userHistIdx = currentHistory.length - 1; // pushed before fetch
@@ -1315,3 +1346,10 @@
// and seed the mode UI (which also calls render_icons internally). // and seed the mode UI (which also calls render_icons internally).
update_mode_ui(); update_mode_ui();
render_icons(); render_icons();
// ── Auto-restore last session ─────────────────────────────────
// Silently resume if within the inactivity TTL; clears stored ID on error.
{
const stored = get_stored_session();
if (stored) resumeSession(stored, true).catch(clear_stored_session);
}