diff --git a/tests/unit/test_chat_web.py b/tests/unit/test_chat_web.py index 2ae16e6..4945213 100644 --- a/tests/unit/test_chat_web.py +++ b/tests/unit/test_chat_web.py @@ -1,6 +1,6 @@ """Offline Chromium tests of real Hope assets; no NAS or live provider.""" from pathlib import Path -from urllib.parse import urlparse +from urllib.parse import urlparse, parse_qs from playwright.sync_api import sync_playwright, expect ROOT = Path(__file__).resolve().parents[2] @@ -22,15 +22,16 @@ def fixture(page, replay=False): elif path == '/api/preferences': if method == 'PUT': preferences.update(body); preferences['revision'] += 1 data = {'preferences': preferences} - elif path == '/api/workspaces': data = {'workspaces': []} + elif path == '/api/workspaces': data = {'workspaces': [dict(id='w1',name='Writing',revision=1,instructions='Keep it concise')]} + elif path == '/api/sync': data = {'changes':[{'kind':'conversation','id':'c1','data':conversations['c1']},{'kind':'preferences','data':preferences}], 'cursor':1} elif path == '/api/conversations': if method == 'POST': conversations['c2'] = dict(conversation, id='c2', title=body['title']); messages['c2'] = [] data = {'conversation': conversations['c2']} - else: data = {'conversations': list(conversations.values()), 'next_cursor': None} + else: data = {'conversations': [c for c in conversations.values() if c['archived'] == (parse_qs(urlparse(r.request.url).query).get('archived',['false'])[0] == 'true')], 'next_cursor': None} elif path.endswith('/events'): streams.append(r.request.url) - message = 'id: 1\nevent: message\ndata: {"message":{"id":"a1","role":"assistant","content":"## Answer\\n**Safe** and `clear`.\\n\\n[bad](javascript:alert(1))","status":"completed"}}\n\n' + message = 'id: 1\nevent: message\ndata: {"message":{"id":"a1","role":"assistant","content":"## Answer\\n| Name | Value |\\n| --- | --- |\\n| | **safe** |\\n**Safe** and `clear`.\\n\\n[bad](javascript:alert(1))","status":"completed"}}\n\n' done = 'id: 2\nevent: done\ndata: {"run":{"id":"r1","status":"completed"}}\n\n' r.fulfill(body=message if replay and len(streams) == 1 else message + done, content_type='text/event-stream'); return elif path.endswith('/messages'): @@ -70,6 +71,8 @@ def test_chat_crud_replay_safe_markdown_and_preserved_branches(): expect(page.locator('#primaryInput')).to_be_enabled(timeout=10000) assert len(streams) == 2 and streams[-1].endswith('after=1') assert page.locator('[data-message-id="a1"]').count() == 1 + assert page.locator('#chatTimeline table').count() == 1 + assert page.locator('#chatTimeline img').count() == 0 assert page.locator('#chatTimeline script').count() == 0 assert page.locator('#chatTimeline a[href^="javascript:"]').count() == 0 assert page.evaluate('window.injected') is None @@ -102,9 +105,48 @@ def test_mobile_drawer_keyboard_voice_and_logout(): 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() expect(page.locator('#loginGate')).to_be_visible() assert 'Original question' not in page.locator('#chatTimeline').inner_text() browser.close() + + +def test_archived_move_sync_conflict_and_mobile_actions(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={'width':390,'height':844}) + calls, streams, conversations, messages = fixture(page) + page.goto('https://hope.test/'); page.wait_for_load_state('networkidle') + expect(page.locator('#conversationMenu')).to_be_hidden() + page.locator('#drawerOpen').click(); page.get_by_role('button',name='A saved thought',exact=True).click() + page.locator('#conversationMenu').click(); expect(page.locator('#moveConversation')).to_be_visible() + page.locator('#moveConversation').click(); page.locator('#chatWorkspaceSelect').select_option('w1'); page.locator('#chatActionSave').click() + expect(page.locator('#chatActionDialog')).not_to_be_visible() + assert conversations['c1']['workspace_id'] == 'w1' + conversations['c1'].update(title='Changed elsewhere', revision=4) + 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') + 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') + 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() + page.get_by_role('button',name='Changed elsewhere',exact=True).click() + def conflict(route): + if route.request.method == 'PATCH': + conversations['c1'].update(title='Newest revision',revision=9) + 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() + expect(page.locator('#chatTitle')).to_have_text('Newest revision') + expect(page.locator('#chatNotice')).to_contain_text('Conversation refreshed') + browser.close() diff --git a/web/chat.js b/web/chat.js index 7313be3..3086537 100644 --- a/web/chat.js +++ b/web/chat.js @@ -2,8 +2,8 @@ (() => { '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, pending: null}; - let searchTimer, listVersion = 0, action; + 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, pending: null}; + let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false; 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; }; @@ -12,7 +12,9 @@ $('primaryInput').disabled = !ready || state.busy || running; $('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim(); $('cancelRun').hidden = terminal(state.run); - for (const id of ['pinConversation', 'renameConversation', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy; + for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy; + $('conversationActions').hidden = !state.conversation; $('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'; } @@ -34,7 +36,8 @@ $('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() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('drawerBackdrop').hidden = true; } } + 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; $('drawerBackdrop').hidden = true; } } mobile.addEventListener('change', adapt); adapt(); $('openVoice').onclick = () => { $('primaryChat').hidden = true; $('legacyVoice').hidden = false; if (mobile.matches) drawer(false); $('backToChat').focus(); }; $('backToChat').onclick = () => { $('primaryChat').hidden = false; $('legacyVoice').hidden = true; $('primaryInput').focus(); }; @@ -54,9 +57,11 @@ } function markdown(text) { const root = document.createElement('div'); root.className = 'markdown'; let code = null, list = null; - for (const line of String(text || '').split('\n')) { + const lines = String(text || '').split('\n'); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const line = lines[lineIndex]; if (line.startsWith('```')) { if (code) {code = null;} else {const pre = document.createElement('pre'); code = document.createElement('code'); pre.append(code); root.append(pre);} list = null; continue; } if (code) {code.append(document.createTextNode(line + '\n')); continue;} + if (line.includes('|') && /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[lineIndex + 1] || '')) {const table = document.createElement('table'), head = document.createElement('thead'), body = document.createElement('tbody'), wrap = document.createElement('div'); wrap.className = 'markdown-table'; const cells = value => value.trim().replace(/^\||\|$/g, '').split('|'); const addRow = (value, parent, header) => {const row = document.createElement('tr'); for (const cell of cells(value)) {const node = document.createElement(header ? 'th' : 'td'); if (header) node.scope = 'col'; inline(node, cell.trim()); row.append(node);} parent.append(row);}; addRow(line, head, true); lineIndex += 1; while (lines[lineIndex + 1]?.includes('|') && lines[lineIndex + 1].trim()) {lineIndex++; addRow(lines[lineIndex], body, false);} table.append(head, body); wrap.append(table); root.append(wrap); list = null; continue;} const item = /^(?:[-*] |\d+\. )(.*)$/.exec(line); if (item) {const type = /^\d/.test(line) ? 'ol' : 'ul'; if (!list || list.tagName.toLowerCase() !== type) {list = document.createElement(type); root.append(list);} const li = document.createElement('li'); inline(li, item[1]); list.append(li); continue;} list = null; if (!line.trim()) continue; @@ -85,7 +90,7 @@ } async function list(append = false) { const version = ++listVersion, epoch = state.epoch; - const params = new URLSearchParams({q: $('conversationSearch').value, archived: 'false', limit: '50'}); + const params = new URLSearchParams({q: $('conversationSearch').value, archived: String(state.archived), limit: '50'}); 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(); } @@ -101,16 +106,18 @@ 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();}; $('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; const data = await api('/api/conversations/' + enc(state.conversation.id), 'PATCH', {revision: state.conversation.revision, ...patch}); if (epoch !== state.epoch) return; selected(data.conversation); await list();} + 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();} $('pinConversation').onclick = () => update({pinned: !state.conversation.pinned}).catch(e => notice(e.message)); function dialog(title, value, fn, description = '', needsValue = true) { - 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(); action = fn; $('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;} }; $('renameConversation').onclick = () => dialog('Rename conversation', state.conversation.title, title => update({title})); - $('archiveConversation').onclick = () => dialog('Archive conversation?', '', async () => {await update({archived: true}); reset(); await list();}, 'The conversation is retained, but leaves Recent.', false); + $('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); + $('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;}; $('deleteConversation').onclick = () => dialog('Delete conversation?', '', async () => {await api('/api/conversations/' + enc(state.conversation.id), 'DELETE'); reset(); await list();}, 'This deletes the conversation, not your account memory.', false); async function branch(message, content, send = false) {const data = await api('/api/conversations/' + enc(state.conversation.id) + '/branch', 'POST', {message_id: message.id, ...(content === undefined ? {} : {content})}); await open(data.conversation.id); if (send) {$('primaryInput').value = content; await submit();} } function edit(message) {dialog('Edit in a new branch', message.content, content => branch(message, content, true), 'Your original conversation stays unchanged.');} @@ -169,10 +176,13 @@ $('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);} }; $('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(), 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) {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; 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'});} controls(); if (!user || !changed) return; - const epoch = state.epoch; + const epoch = state.epoch; syncTimer = setInterval(sync, 5000); Promise.all([list(), workspaces(), 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 930169a..56617ac 100644 --- a/web/index.html +++ b/web/index.html @@ -28,8 +28,8 @@
- -

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.