feat: clarify web library memory and conversation management
Rebuild & Deploy / rebuild (push) Failing after 17s

This commit is contained in:
snowspeeder committed 2026-10-04 07:58:16 -04:00
1 parent 3183ebaea7
commit 41ca590744
11 files changed
+767 -70

No files matched your search

+60 -9
View File
@@ -3,10 +3,13 @@
'use strict';
const $ = id => document.getElementById(id), api = (path, method = 'GET', body, signal) => HopeAuth.request(path, {method, ...(body === undefined ? {} : {body: JSON.stringify(body)}), signal});
const state = {conversation: null, messages: [], run: null, workspaces: [], preferences: null, epoch: 0, stream: null, cursor: null, messageCursor: null, workspace: null, busy: false, voiceActive: false, archived: false, syncCursor: 0, research: false, pending: null};
let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false;
let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false, accountEpoch = 0, rowMenu;
const sidebarWrites = new Set();
const selectedLocked = id => id === state.conversation?.id && (state.busy || !!state.stream || !terminal(state.run) || state.voiceActive);
function closeRowMenu() {if (rowMenu?.matches(':popover-open')) rowMenu.hidePopover();}
const enc = encodeURIComponent, terminal = run => !run || ['completed', 'failed', 'cancelled'].includes(run.status);
const notice = text => { $('chatNotice').textContent = text; };
const button = (text, fn, cls = 'btn btn-ghost') => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; b.addEventListener('click', () => Promise.resolve().then(fn).catch(error => notice(error.message))); return b; };
const button = (text, fn, cls = 'btn btn-ghost') => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; b.addEventListener('click', () => {const epoch = accountEpoch; Promise.resolve().then(fn).catch(error => {if (epoch === accountEpoch) notice(error.message);});}); return b; };
function controls() {
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || !!state.stream || state.voiceActive;
$('primaryInput').disabled = !ready || state.busy || !terminal(state.run) || !!state.stream; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy));
@@ -34,7 +37,7 @@
(open ? $('drawerClose') : $('drawerOpen')).focus();
}
$('drawerOpen').onclick = () => drawer(true); $('drawerClose').onclick = $('drawerBackdrop').onclick = () => drawer(false);
document.addEventListener('keydown', event => { if (event.key === 'Escape' && $('chatSidebar').classList.contains('drawer-open')) drawer(false); if ((event.metaKey || event.ctrlKey) && event.key === 'k') {event.preventDefault(); drawer(true); $('conversationSearch').focus();} });
document.addEventListener('keydown', event => { if (event.key === 'Escape' && rowMenu?.matches(':popover-open')) {closeRowMenu(); return;} if (event.key === 'Escape' && $('chatSidebar').classList.contains('drawer-open')) drawer(false); if ((event.metaKey || event.ctrlKey) && event.key === 'k') {event.preventDefault(); drawer(true); $('conversationSearch').focus();} });
const mobile = matchMedia('(max-width: 760px)');
function adapt() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('legacyVoice').inert = false; $('drawerBackdrop').hidden = true; } }
mobile.addEventListener('change', adapt); adapt();
@@ -108,10 +111,58 @@
if (state.workspace) params.set('workspace_id', state.workspace); if (append && state.cursor) params.set('cursor', state.cursor);
const data = await api('/api/conversations?' + params); if (version !== listVersion || epoch !== state.epoch) return;
if (!append) { $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); }
for (const conversation of data.conversations) {const b = button(conversation.title || 'Untitled chat', () => open(conversation.id), 'conversation-link'); b.setAttribute('aria-current', conversation.id === state.conversation?.id ? 'page' : 'false'); $(conversation.pinned ? 'pinnedConversations' : 'recentConversations').append(b);}
closeRowMenu();
for (const conversation of data.conversations) {
const row = document.createElement('div'); row.className = 'conversation-row'; row.dataset.conversationId = conversation.id;
const b = button(conversation.title || 'Untitled chat', () => open(conversation.id), 'conversation-link'); b.setAttribute('aria-current', conversation.id === state.conversation?.id ? 'page' : 'false');
const actions = button('•••', () => showRowMenu(conversation, actions), 'conversation-row-actions'); actions.setAttribute('aria-label', 'Actions for ' + (conversation.title || 'Untitled chat')); actions.setAttribute('aria-haspopup', 'dialog'); actions.setAttribute('aria-expanded', 'false');
row.append(b, actions); $(conversation.pinned ? 'pinnedConversations' : 'recentConversations').append(row);
}
state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor;
}
function showRowMenu(conversation, trigger) {
closeRowMenu(); rowMenu?.remove();
const menu = document.createElement('div'); rowMenu = menu; menu.id = 'conversationRowActions'; trigger.setAttribute('aria-controls', menu.id); menu.className = 'conversation-row-menu'; menu.popover = 'auto'; menu.setAttribute('role', 'dialog'); menu.setAttribute('aria-label', 'Actions for ' + (conversation.title || 'Untitled chat'));
const epoch = accountEpoch, user = HopeAuth.user?.id;
const current = () => epoch === accountEpoch && user === HopeAuth.user?.id;
const pin = button(conversation.pinned ? 'Unpin' : 'Pin', async () => {closeRowMenu(); if (current()) await updateConversation(conversation, {pinned: !conversation.pinned});});
const rename = button('Rename', () => {closeRowMenu(); if (current()) dialog('Rename conversation', conversation.title, title => updateConversation(conversation, {title}));});
const archive = button(conversation.archived ? 'Unarchive' : 'Archive', () => {closeRowMenu(); if (current()) dialog(conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', () => updateConversation(conversation, {archived: !conversation.archived}), 'The conversation is retained. Only its archive status changes.', false);});
for (const b of [pin, rename, archive]) b.disabled = selectedLocked(conversation.id) || sidebarWrites.has(conversation.id);
menu.append(pin, rename, archive); $('chatSidebar').append(menu);
menu.addEventListener('toggle', () => {const expanded = menu.matches(':popover-open'); trigger.setAttribute('aria-expanded', String(expanded)); if (!expanded && trigger.isConnected && menu.contains(document.activeElement)) trigger.focus();});
menu.showPopover(); trigger.setAttribute('aria-expanded', 'true');
// The native top layer escapes the scrolling sidebar; clamp the menu to the viewport.
const rect = trigger.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.margin = '0';
menu.style.left = Math.max(8, Math.min(rect.right - menu.offsetWidth, innerWidth - menu.offsetWidth - 8)) + 'px';
menu.style.top = Math.max(8, Math.min(rect.bottom, innerHeight - menu.offsetHeight - 8)) + 'px';
menu.querySelector('button:not(:disabled)')?.focus();
}
async function updateConversation(conversation, patch) {
const epoch = accountEpoch, selectionEpoch = state.epoch, user = HopeAuth.user?.id, cid = conversation.id;
const current = () => epoch === accountEpoch && user === HopeAuth.user?.id;
if (!user || sidebarWrites.has(cid)) return;
if (selectedLocked(cid)) throw new Error('Wait until this conversation is idle before changing it.');
sidebarWrites.add(cid);
try {
let data;
try {data = await api('/api/conversations/' + enc(cid), 'PATCH', {revision: conversation.revision, ...patch});}
catch (error) {
if (!current()) return;
let refreshed = false;
try {const latest = await api('/api/conversations/' + enc(cid)); if (!current()) return; Object.assign(conversation, latest.conversation); if (selectionEpoch === state.epoch && cid === state.conversation?.id) selected(latest.conversation); await list(); refreshed = true;} catch {/* Keep the original mutation failure visible even if refresh also fails. */}
if (!current()) return;
throw new Error(error.message + (refreshed ? ' Conversation refreshed. Review the latest version and try again.' : ' Could not refresh the conversation. Retry when the connection is available.'));
}
if (!current()) return;
Object.assign(conversation, data.conversation);
if (selectionEpoch === state.epoch && cid === state.conversation?.id) {if ('archived' in patch && !selectedLocked(cid)) reset(); else selected(data.conversation);}
// Restart the current search/workspace/archive page: writes can change ordering and cursors.
await list();
} finally {if (current()) sidebarWrites.delete(cid);}
}
async function open(id) {
closeRowMenu();
if (state.voiceActive && id !== state.conversation?.id) {notice('End voice before opening another conversation. Your current thread stays here.'); return;}
stopStream(); const epoch = ++state.epoch; state.run = null; state.messages = []; state.pending = null; notice('Loading conversation…'); controls();
const data = await api('/api/conversations/' + enc(id)); if (epoch !== state.epoch) return;
@@ -123,15 +174,15 @@
$('conversationSearch').oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => list().catch(e => notice(e.message)), 200); };
$('showArchived').onclick = () => {state.archived = !state.archived; $('showArchived').setAttribute('aria-pressed', String(state.archived)); $('recentHeading').textContent = state.archived ? 'Archived' : 'Recent'; list().catch(e => notice(e.message));};
$('loadMoreConversations').onclick = () => list(true).catch(e => notice(e.message));
async function update(patch) {const epoch = state.epoch; let data; try {data = await api('/api/conversations/' + enc(state.conversation.id), 'PATCH', {revision: state.conversation.revision, ...patch});} catch (error) {await refreshSelected(false); await list(); throw new Error(error.message + ' Conversation refreshed. Review the latest version and try again.');} if (epoch !== state.epoch) return; selected(data.conversation); await list();}
async function update(patch) {if (state.conversation) await updateConversation(state.conversation, patch);}
$('pinConversation').onclick = () => update({pinned: !state.conversation.pinned}).catch(e => notice(e.message));
function dialog(title, value, fn, description = '', needsValue = true) {
$('chatWorkspaceSelect').hidden = true; $('chatWorkspaceLabel').hidden = true; if ($('conversationActions').matches(':popover-open')) $('conversationActions').hidePopover(); action = fn; $('chatActionTitle').textContent = title; $('chatActionDescription').textContent = description; $('chatActionValue').value = value; $('chatActionValue').hidden = !needsValue; $('chatActionLabel').hidden = !needsValue; $('chatActionValue').required = needsValue; $('chatActionError').textContent = ''; $('chatActionDialog').showModal();
$('chatWorkspaceSelect').hidden = true; $('chatWorkspaceLabel').hidden = true; if ($('conversationActions').matches(':popover-open')) $('conversationActions').hidePopover(); const epoch = accountEpoch; action = value => epoch === accountEpoch ? fn(value) : undefined; $('chatActionTitle').textContent = title; $('chatActionDescription').textContent = description; $('chatActionValue').value = value; $('chatActionValue').hidden = !needsValue; $('chatActionLabel').hidden = !needsValue; $('chatActionValue').required = needsValue; $('chatActionError').textContent = ''; $('chatActionDialog').showModal();
}
$('chatActionCancel').onclick = () => $('chatActionDialog').close();
$('chatActionForm').onsubmit = async event => {event.preventDefault(); $('chatActionSave').disabled = true; try {await action($('chatActionValue').value.trim()); $('chatActionDialog').close();} catch (e) {$('chatActionError').textContent = e.message;} finally {$('chatActionSave').disabled = false;} };
$('chatActionForm').onsubmit = async event => {event.preventDefault(); const epoch = accountEpoch; $('chatActionSave').disabled = true; try {await action($('chatActionValue').value.trim()); if (epoch === accountEpoch) $('chatActionDialog').close();} catch (e) {if (epoch === accountEpoch) $('chatActionError').textContent = e.message;} finally {if (epoch === accountEpoch) $('chatActionSave').disabled = false;} };
$('renameConversation').onclick = () => dialog('Rename conversation', state.conversation.title, title => update({title}));
$('archiveConversation').onclick = () => dialog(state.conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', async () => {await update({archived: !state.conversation.archived}); reset(); await list();}, 'The conversation is retained, but leaves Recent.', false);
$('archiveConversation').onclick = () => dialog(state.conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', () => update({archived: !state.conversation.archived}), 'The conversation is retained, but leaves Recent.', false);
async function exportChat(format) {const epoch = state.epoch, cid = state.conversation.id; const response = await fetch('/api/conversations/' + enc(cid) + '/export?format=' + format, {credentials:'same-origin'}); if (response.status === 401) {HopeAuth.signOut(); return;} if (!response.ok) throw new Error('Download failed (' + response.status + ').'); const blob = await response.blob(); if (epoch !== state.epoch) return; const url = URL.createObjectURL(blob), anchor = document.createElement('a'); anchor.href = url; anchor.download = 'hope-conversation-' + cid.replace(/[^a-zA-Z0-9_-]/g,'') + (format === 'json' ? '.json' : '.md'); anchor.click(); setTimeout(() => URL.revokeObjectURL(url),1000); notice('Conversation downloaded.');}
$('exportMarkdown').onclick = () => exportChat('markdown').catch(e => notice(e.message)); $('exportJSON').onclick = () => exportChat('json').catch(e => notice(e.message));
$('moveConversation').onclick = () => {dialog('Move conversation', '', () => update({workspace_id: $('chatWorkspaceSelect').value || null}), 'Keep the same history in a different workspace.', false); const select = $('chatWorkspaceSelect'); select.replaceChildren(); for (const workspace of [{id:'',name:'No workspace'}, ...state.workspaces]) {const option = document.createElement('option'); option.value = workspace.id; option.textContent = workspace.name; select.append(option);} select.value = state.conversation.workspace_id || ''; select.hidden = false; $('chatWorkspaceLabel').hidden = false;};
@@ -209,7 +260,7 @@
async function sync() {if (syncBusy || !HopeAuth.user || document.hidden) return; syncBusy = true; const epoch = state.epoch; try {const data = await api('/api/sync?after=' + state.syncCursor); if (epoch !== state.epoch) return; state.syncCursor = data.cursor; if (!data.changes?.length) return; for (const change of data.changes) {if (change.kind === 'preferences' && change.data && !$('preferencesDialog').open && !(window.HopeSettings?.isOpen && HopeSettings.section === 'appearance')) {state.preferences = change.data; theme(change.data);} if (change.kind === 'conversation' && change.id === state.conversation?.id && change.deleted) {reset(); notice('This conversation was deleted on another device.');}} await Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), refreshSelected()]);} catch (error) {/* A subsequent foreground poll retries; never discard the draft. */} finally {syncBusy = false;}}
document.addEventListener('visibilitychange', () => {if (!document.hidden) sync();});
HopeAuth.onChange((user, changed) => {
if (changed || !user) {clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; state.research = false; $('researchMode').hidden = true; $('researchModeLabel').hidden = true; $('researchMode').value = 'normal'; reset(); state.workspace = null; state.workspaces = []; state.preferences = null; $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); $('workspaceList').replaceChildren(); $('legacyVoice').hidden = true; $('primaryChat').hidden = false; $('preferencesDialog').close(); $('chatActionDialog').close(); $('libraryDialog').close(); theme({palette: 'opal', appearance: 'system'});}
if (changed || !user) {++accountEpoch; closeRowMenu(); rowMenu?.remove(); rowMenu = null; sidebarWrites.clear(); $('chatActionSave').disabled = false; clearTimeout(searchTimer); clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; state.research = false; $('researchMode').hidden = true; $('researchModeLabel').hidden = true; $('researchMode').value = 'normal'; reset(); state.workspace = null; state.workspaces = []; state.preferences = null; $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); $('workspaceList').replaceChildren(); $('legacyVoice').hidden = true; $('primaryChat').hidden = false; $('preferencesDialog').close(); $('chatActionDialog').close(); $('libraryDialog').close(); theme({palette: 'opal', appearance: 'system'});}
controls(); if (!user || !changed) return;
const epoch = state.epoch; syncTimer = setInterval(sync, 5000);
Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), api('/api/preferences').then(data => {if (epoch === state.epoch) {state.preferences = data.preferences; theme(data.preferences);}})]).catch(e => {if (epoch === state.epoch) notice(e.message);});