New chat
A little space to think.
Ask a question, work through an idea, or start something new.
Your chats are private to your account. Hope can make mistakes.
diff --git a/tests/unit/test_chat_web.py b/tests/unit/test_chat_web.py index 203d9b4..8ad407a 100644 --- a/tests/unit/test_chat_web.py +++ b/tests/unit/test_chat_web.py @@ -73,6 +73,7 @@ def test_chat_crud_replay_safe_markdown_and_preserved_branches(): page.goto('https://hope.test/'); page.wait_for_load_state('networkidle') page.get_by_role('button',name='A saved thought',exact=True).click() expect(page.locator('#chatTimeline')).to_contain_text('Original question') + page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()") page.locator('#pinConversation').click(); expect(page.locator('#pinConversation')).to_have_text('Unpin') page.locator('#primaryInput').fill('A new question'); page.locator('#primarySend').click() expect(page.locator('#chatTimeline')).to_contain_text('Answer') @@ -90,11 +91,12 @@ def test_chat_crud_replay_safe_markdown_and_preserved_branches(): expect(page.locator('#chatTitle')).to_have_text('New branch') expect(page.locator('#chatTimeline')).to_contain_text('Revised question') assert messages['c1'][0]['content'] == 'Original question' - page.locator('#openPreferences').click(); page.locator('#paletteSelect').select_option('ocean') + page.locator('#openPreferences').click(); expect(page.locator('#preferencesMessage')).to_have_text(''); page.locator('#paletteSelect').select_option('ocean') page.get_by_role('button',name='Save appearance').click(); expect(page.locator('html')).to_have_attribute('data-palette','ocean') - page.locator('#closePreferences').click(); page.screenshot(path='/tmp/hope-chat-desktop.png') + page.locator('#settingsClose').click(); page.screenshot(path='/tmp/hope-chat-desktop.png') + page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()") page.locator('#deleteConversation').click(); page.locator('#chatActionSave').click() - expect(page.locator('#chatTitle')).to_have_text('New chat'); assert 'branch' not in conversations + expect(page.locator('#chatTitle')).to_have_text('New conversation'); assert 'branch' not in conversations assert errors == [] browser.close() @@ -106,17 +108,9 @@ def test_mobile_drawer_keyboard_voice_and_logout(): page.goto('https://hope.test/'); page.wait_for_load_state('networkidle') page.locator('#drawerOpen').click(); expect(page.locator('#drawerOpen')).to_have_attribute('aria-expanded','true') page.keyboard.press('Escape'); expect(page.locator('#drawerOpen')).to_be_focused() - page.locator('#drawerOpen').click(); page.locator('#openVoice').click() - expect(page.locator('#startBtn')).to_be_visible(); expect(page.locator('#settingsBtn')).to_be_visible() - page.locator('#backToChat').click(); expect(page.locator('#primaryChat')).to_be_visible() - page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))") - expect(page.locator('#primaryInput')).to_be_disabled() - page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:false}}))") - expect(page.locator('#primaryInput')).to_be_enabled() - page.wait_for_function("document.getElementById('chatSidebar').getBoundingClientRect().right <= 0") - page.screenshot(path='/tmp/hope-chat-mobile.png') - assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') - page.locator('#drawerOpen').click(); page.locator('#openVoice').click(); page.locator('#logoutBtn').click() + page.locator('#drawerOpen').click(); page.locator('#accountMenuBtn').click() + expect(page.locator('#settingsDialog')).to_be_visible() + page.locator('#logoutBtn').click() expect(page.locator('#loginGate')).to_be_visible() assert 'Original question' not in page.locator('#chatTimeline').inner_text() browser.close() @@ -138,12 +132,12 @@ def test_archived_move_sync_conflict_and_mobile_actions(): page.evaluate("document.dispatchEvent(new Event('visibilitychange'))") expect(page.locator('#chatTitle')).to_have_text('Changed elsewhere') page.locator('#conversationMenu').click(); page.locator('#archiveConversation').click(); page.locator('#chatActionSave').click() - expect(page.locator('#chatTitle')).to_have_text('New chat') + expect(page.locator('#chatTitle')).to_have_text('New conversation') page.locator('#drawerOpen').click(); page.locator('#showArchived').click() page.get_by_role('button',name='Changed elsewhere',exact=True).click() page.locator('#conversationMenu').click(); expect(page.locator('#archiveConversation')).to_have_text('Unarchive') page.locator('#archiveConversation').click(); page.locator('#chatActionSave').click() - expect(page.locator('#chatTitle')).to_have_text('New chat') + expect(page.locator('#chatTitle')).to_have_text('New conversation') assert conversations['c1']['archived'] is False # A stale optimistic write refreshes rather than overwriting another client's title. page.locator('#drawerOpen').click(); page.locator('#showArchived').click() @@ -154,7 +148,8 @@ def test_archived_move_sync_conflict_and_mobile_actions(): route.fulfill(status=409,json={'error':'Stale revision'}) else: route.fallback() page.route('https://hope.test/api/conversations/c1', conflict) - page.locator('#conversationMenu').click(); page.locator('#pinConversation').click() + page.locator('#conversationMenu').click(); page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()") + page.locator('#pinConversation').click() expect(page.locator('#chatTitle')).to_have_text('Newest revision') expect(page.locator('#chatNotice')).to_contain_text('Conversation refreshed') browser.close() @@ -180,7 +175,7 @@ def test_voice_history_refresh_keyboard_focus_and_large_text(): page.screenshot(path='/tmp/hope-chat-opal-desktop.png') page.keyboard.press('Control+k'); expect(page.locator('#conversationSearch')).to_be_focused() page.keyboard.press('Escape') - page.locator('#openPreferences').click(); expect(page.locator('#preferencesDialog')).to_be_visible() + page.locator('#openPreferences').click(); expect(page.locator('#settingsDialog')).to_be_visible() page.keyboard.press('Escape'); expect(page.locator('#openPreferences')).to_be_focused() # Native controls remain usable at large text and narrow effective viewport. page.set_viewport_size({'width':640,'height':900}) @@ -202,8 +197,10 @@ def test_qualified_research_and_private_export(): page.locator('#primaryInput').fill('Research this topic'); page.locator('#primarySend').click() expect(page.locator('#chatTimeline')).to_contain_text('Answer') assert next(body for method,path,body in calls if method == 'POST' and path.endswith('/messages'))['mode'] == 'research' + page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()") with page.expect_download() as info: page.locator('#exportMarkdown').click() assert info.value.suggested_filename.endswith('.md') + page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()") with page.expect_download() as info: page.locator('#exportJSON').click() assert info.value.suggested_filename.endswith('.json') assert all('/api/conversations/' in path for method,path,body in calls if path.endswith('/export')) @@ -219,6 +216,7 @@ def test_immediate_pin_after_response_uses_final_server_revision(): expect(page.locator('#chatTitle')).to_have_text('A saved thought') page.locator('#primaryInput').fill('Finish and immediately pin'); page.locator('#primarySend').click() expect(page.locator('#chatTimeline')).to_contain_text('Answer') + page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()") page.locator('#pinConversation').click() expect(page.locator('#pinConversation')).to_have_text('Unpin') assert conversations['c1']['pinned'] is True @@ -228,25 +226,30 @@ def test_immediate_pin_after_response_uses_final_server_revision(): browser.close() -def test_realtime_voice_is_prominent_on_desktop_and_mobile(): +def test_realtime_voice_is_inline_in_same_thread_and_creates_real_conversation(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) - for width in [1280,390]: + for width in [1440, 390, 320]: page = browser.new_page(viewport={'width':width,'height':900}) + errors = []; page.on('pageerror', lambda error: errors.append(str(error))) calls, streams, conversations, messages = fixture(page) + page.add_init_script("window.addEventListener('hope:start-voice', event => {event.stopImmediatePropagation(); window.startedVoice = event.detail.conversation_id; window.dispatchEvent(new CustomEvent('hope:voice-state', {detail:{active:true,conversation_id:event.detail.conversation_id}}));}, true)") page.goto('https://hope.test/'); page.wait_for_load_state('networkidle') - expect(page.get_by_role('button',name='Talk to Hope',exact=True)).to_be_visible() + # Replace only voice transport; durable conversation creation still uses real chat.js. + + expect(page.locator('#primaryComposer #talkToHope')).to_be_visible() + assert page.locator('.chat-topbar #talkToHope').count() == 0 page.locator('#primaryInput').fill('Keep this draft while I talk') page.locator('#talkToHope').click() expect(page.locator('#legacyVoice')).to_be_visible() - expect(page.locator('#startBtn')).to_be_visible() - expect(page.locator('#voiceModeBtn')).to_have_attribute('aria-pressed','true') - expect(page.locator('#backToChat')).to_be_visible() - page.locator('#backToChat').click() + expect(page.locator('#primaryChat')).to_be_visible() expect(page.locator('#primaryInput')).to_have_value('Keep this draft while I talk') - expect(page.locator('#talkToHope')).to_be_visible() - assert not any(path == '/token' for method,path,body in calls) + expect(page.locator('#primaryInput')).to_be_enabled() + expect(page.locator('#primarySend')).to_be_disabled() + assert page.evaluate('window.startedVoice') == 'c2' + assert any(method == 'POST' and path == '/api/conversations' for method,path,body in calls) assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') page.screenshot(path=f'/tmp/hope-talk-visible-{width}.png') + assert not errors page.close() browser.close() diff --git a/web/chat.js b/web/chat.js index 225b4c1..2c7fff1 100644 --- a/web/chat.js +++ b/web/chat.js @@ -9,14 +9,14 @@ 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; }; function controls() { const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || !!state.stream || state.voiceActive; - $('primaryInput').disabled = !ready || state.busy || running; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy)); + $('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)); $('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim(); $('cancelRun').hidden = terminal(state.run); $('researchMode').disabled = !ready || running || state.busy; for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'exportMarkdown', 'exportJSON', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy || !!state.stream || !terminal(state.run); - $('conversationActions').hidden = !state.conversation; $('conversationMenu').hidden = !state.conversation; + $('talkToHope').disabled = !ready || state.busy || !!state.stream || !terminal(state.run); $('conversationMenu').hidden = !state.conversation; $('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive'; $('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin'; - $('chatTitle').textContent = state.conversation?.title || 'New chat'; + $('chatTitle').textContent = state.conversation?.title || 'New conversation'; } function selected(conversation) { state.conversation = conversation; @@ -36,12 +36,25 @@ $('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();} }); const mobile = matchMedia('(max-width: 760px)'); - function adapt() { if (mobile.matches) $('conversationActions').setAttribute('popover', 'auto'); else {$('conversationActions').removeAttribute('popover');} - $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('legacyVoice').inert = false; $('drawerBackdrop').hidden = true; } } + 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(); - function showVoice() { $('primaryChat').hidden = true; $('legacyVoice').hidden = false; if (mobile.matches) drawer(false); $('voiceModeBtn').click(); $('startBtn').focus(); } - $('openVoice').onclick = showVoice; $('talkToHope').onclick = showVoice; - $('backToChat').onclick = () => { $('primaryChat').hidden = false; $('legacyVoice').hidden = true; $('primaryInput').focus(); }; + async function startVoice() { + if (state.voiceActive) { $('legacyVoice').hidden = false; $('muteBtn').focus(); return; } + if (state.busy || state.stream || !terminal(state.run) || HopeAuth.user?.status !== 'ready') return; + const epoch = state.epoch; state.busy = true; controls(); + try { + if (!state.conversation) {const data = await api('/api/conversations', 'POST', {title: 'Voice conversation', ...(state.workspace ? {workspace_id: state.workspace} : {})}); if (epoch !== state.epoch) return; selected(data.conversation); await list();} + if (epoch !== state.epoch) return; + $('legacyVoice').hidden = false; + window.dispatchEvent(new CustomEvent('hope:start-voice', {detail: {conversation_id: state.conversation.id}})); + } catch (error) {if (epoch === state.epoch) notice(error.message);} finally {if (epoch === state.epoch) {state.busy = false; controls();}} + } + $('talkToHope').onclick = startVoice; + $('openVoice').onclick = startVoice; + $('backToChat').onclick = () => $('primaryInput').focus(); + document.querySelectorAll('.lightwell-symbol').forEach(el => {for (let i=0;i<25;i++) el.append(document.createElement('i'));}); + $('openLibrary').onclick = () => {if (mobile.matches) drawer(false); $('libraryDialog').showModal();}; + $('closeLibrary').onclick = () => $('libraryDialog').close(); // Markdown uses DOM text nodes only: HTML is always literal; unsafe URLs never become links. function inline(parent, text) { const expression = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^\s)]+\))/g; @@ -74,7 +87,7 @@ const timeline = $('chatTimeline'), atBottom = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 100, scroll = timeline.scrollTop; timeline.replaceChildren(); if (state.messageCursor) timeline.append(button('Load earlier messages', olderMessages)); - if (!state.messages.length) { const welcome = document.createElement('div'); welcome.className = 'chat-welcome'; const h = document.createElement('h2'); h.textContent = 'A little space to think.'; const p = document.createElement('p'); p.textContent = 'Ask a question, work through an idea, or start something new.'; welcome.append(h, p); timeline.append(welcome); } + if (!state.messages.length) { const welcome = document.createElement('div'); welcome.className = 'chat-welcome'; const mark = document.createElement('span'); mark.className = 'hope-mark'; mark.setAttribute('aria-hidden','true'); const h = document.createElement('h2'); h.textContent = 'A little space for you.'; const p = document.createElement('p'); p.textContent = 'Think out loud, work something through, or make something new. You can type, or talk to Hope.'; welcome.append(mark, h, p); timeline.append(welcome); } for (const message of state.messages) { const row = document.createElement('article'); row.className = `chat-message ${message.role}`; row.dataset.messageId = message.id; const name = document.createElement('h2'); name.textContent = message.role === 'user' ? 'You' : 'Hope'; row.append(name, markdown(message.content)); @@ -99,13 +112,14 @@ state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor; } async function open(id) { + 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; selected(data.conversation); state.messages = data.messages || []; state.messageCursor = data.next_cursor; state.run = (data.runs || []).find(r => !terminal(r)) || null; render(); notice(''); if (mobile.matches) drawer(false); if (state.run) stream(state.run); await list(); } async function olderMessages() {const epoch = state.epoch, timeline = $('chatTimeline'), height = timeline.scrollHeight; const data = await api('/api/conversations/' + enc(state.conversation.id) + '?' + new URLSearchParams({cursor: state.messageCursor, limit: '50'})); if (epoch !== state.epoch) return; const ids = new Set(state.messages.map(m => m.id)); state.messages = (data.messages || []).filter(m => !ids.has(m.id)).concat(state.messages); state.messageCursor = data.next_cursor; render(); timeline.scrollTop += timeline.scrollHeight - height;} function reset() { stopStream(); ++state.epoch; state.messages = []; state.messageCursor = null; state.run = null; state.pending = null; state.busy = false; selected(null); render(); notice(''); $('primaryInput').value = ''; controls(); } - $('newConversation').onclick = () => {reset(); if (mobile.matches) drawer(false); $('primaryInput').focus();}; + $('newConversation').onclick = () => {if (state.voiceActive) {notice('End voice before starting a new conversation.'); return;} reset(); if (mobile.matches) drawer(false); $('primaryInput').focus();}; $('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)); @@ -170,20 +184,32 @@ $('cancelRun').onclick = async () => {try {const data = await api('/api/runs/' + enc(state.run.id) + '/cancel', 'POST'); state.run = data.run; controls();} catch (e) {notice(e.message);} }; function workspaceRows() { $('workspaceList').replaceChildren(button('All conversations', () => {state.workspace = null; list();}, 'conversation-link')); - for (const workspace of state.workspaces) { const row = document.createElement('div'); row.className = 'workspace-row'; row.append(button(workspace.name, () => {state.workspace = workspace.id; notice('Workspace: ' + workspace.name + '. New chats will be created here.'); list();}, 'conversation-link'), button('Instructions', () => workspaceInstructions(workspace)), button('Edit', () => dialog('Rename workspace', workspace.name, async name => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, name}); await workspaces();})), button('Remove', () => dialog('Remove workspace?', '', async () => {await api('/api/workspaces/' + enc(workspace.id), 'DELETE'); if (state.workspace === workspace.id) state.workspace = null; await workspaces(); await list();}, 'Its conversations are kept outside the workspace.', false))); $('workspaceList').append(row); } + for (const workspace of state.workspaces) { + const row = document.createElement('div'); row.className = 'workspace-row'; + row.append(button(workspace.name, () => {state.workspace = workspace.id; notice('Workspace: ' + workspace.name + '. New chats will be created here.'); list();}, 'conversation-link')); + const menu = document.createElement('details'), summary = document.createElement('summary'), tools = document.createElement('div'); menu.className = 'workspace-menu'; summary.textContent = '•••'; summary.setAttribute('aria-label', workspace.name + ' workspace actions'); tools.className = 'workspace-tools'; + tools.append(button('Instructions', () => {menu.open = false; workspaceInstructions(workspace);}), button('Rename', () => {menu.open = false; dialog('Rename workspace', workspace.name, async name => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, name}); await workspaces();});}), button('Remove', () => {menu.open = false; dialog('Remove workspace?', '', async () => {await api('/api/workspaces/' + enc(workspace.id), 'DELETE'); if (state.workspace === workspace.id) state.workspace = null; await workspaces(); await list();}, 'Its conversations are kept outside the workspace.', false);})); + menu.append(summary, tools); row.append(menu); $('workspaceList').append(row); + } } async function workspaces() {const epoch = state.epoch, data = await api('/api/workspaces'); if (epoch !== state.epoch) return; state.workspaces = data.workspaces; workspaceRows();} function workspaceInstructions(workspace) {dialog('Workspace instructions', workspace.instructions || '', async instructions => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, instructions}); await workspaces();}, 'These instructions guide chats in this workspace. They do not isolate tool access.');} $('newWorkspace').onclick = () => dialog('Create workspace', '', async name => {await api('/api/workspaces', 'POST', {name}); await workspaces();}, 'Group related chats. A workspace is not a separate tool sandbox.'); function theme(preferences) {document.documentElement.dataset.palette = preferences.palette; document.documentElement.dataset.appearance = preferences.appearance; $('paletteSelect').value = preferences.palette; $('appearanceSelect').value = preferences.appearance;} - $('openPreferences').onclick = async () => {try {const data = await api('/api/preferences'); state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = ''; if (!HopeAuth.user) return; $('preferencesDialog').showModal();} catch(e) {notice(e.message);} }; + window.HopeAppearance = {load: async () => { + const epoch = state.epoch, user = HopeAuth.user?.id, fields = [...$('preferencesForm').querySelectorAll('select, button[type=submit], button:not([type])')]; + fields.forEach(field => field.disabled = true); + try {const data = await api('/api/preferences'); if (epoch !== state.epoch || user !== HopeAuth.user?.id) return; state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = '';} + finally {if (epoch === state.epoch && user === HopeAuth.user?.id) fields.forEach(field => field.disabled = false);} + }}; + $('openPreferences').onclick = async () => {try {if (window.HopeSettings) {await HopeSettings.open('appearance'); return;} await HopeAppearance.load(); if (!HopeAuth.user) return; $('preferencesDialog').showModal();} catch(e) {notice(e.message);} }; $('closePreferences').onclick = () => $('preferencesDialog').close(); $('preferencesForm').onsubmit = async event => {event.preventDefault(); try {const data = await api('/api/preferences', 'PUT', {revision: state.preferences.revision, palette: $('paletteSelect').value, appearance: $('appearanceSelect').value}); state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = 'Appearance saved.';} catch(e) {$('preferencesMessage').textContent = e.message;} }; async function refreshSelected(messages = true) {if (!state.conversation) return; const epoch = state.epoch, cid = state.conversation.id; const data = await api('/api/conversations/' + enc(cid)); if (epoch !== state.epoch || cid !== state.conversation?.id) return; selected(data.conversation); if (messages && terminal(state.run)) {const timeline = $('chatTimeline'), scroll = timeline.scrollTop; const incoming = new Map((data.messages || []).map(m => [m.id,m])); state.messages = state.messages.map(m => incoming.get(m.id) || m); for (const m of incoming.values()) if (!state.messages.some(old => old.id === m.id)) state.messages.push(m); render(); timeline.scrollTop = scroll;} } - 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) {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;}} + 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(); theme({palette: 'opal', appearance: 'system'});} + 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'});} 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);}); diff --git a/web/index.html b/web/index.html index 8f45b7b..25f4b1a 100644 --- a/web/index.html +++ b/web/index.html @@ -14,8 +14,7 @@
- Skip to conversation - +Skip to conversationSign in to your private voice and text companion.
@@ -27,134 +26,32 @@Checking your session…
Ask a question, work through an idea, or start something new.
Your chats are private to your account. Hope can make mistakes.
Live voice controls and account settings.
Voice and text companion
-Create a private assistant for each person.
Loading accounts…
-| Account | Role | Status | Container | Error | Actions |
|---|
Speak naturally. I’ll listen, remember, and answer in real time.
-- - -
-Live voice transcript
-Your voice preference applies to this conversation.
-Your private conversation
Ready when you are
Speak naturally. You can interrupt Hope.
Thinking…Speak to interruptYour conversation, private to you.Enter to send · Shift + Enter for a new line
+Progress from your private assistant.