diff --git a/tests/unit/test_chat_web.py b/tests/unit/test_chat_web.py index dc25523..892fc00 100644 --- a/tests/unit/test_chat_web.py +++ b/tests/unit/test_chat_web.py @@ -36,6 +36,8 @@ def fixture(page, replay=False): streams.append(r.request.url) 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' + if not replay or len(streams) > 1: + for c in conversations.values(): c['revision'] += 1 r.fulfill(body=message if replay and len(streams) == 1 else message + done, content_type='text/event-stream'); return elif path.endswith('/messages'): cid = path.split('/')[3]; message = dict(id='u2', role='user', content=body['content'], status='completed'); messages[cid].append(message) @@ -46,7 +48,10 @@ def fixture(page, replay=False): elif path.startswith('/api/conversations/'): cid = path.split('/')[3] if method == 'DELETE': del conversations[cid]; data = {'ok':True} - elif method == 'PATCH': conversations[cid].update(body); conversations[cid]['revision'] += 1; data = {'conversation':conversations[cid]} + elif method == 'PATCH': + if body['revision'] != conversations[cid]['revision']: + r.fulfill(status=409,json={'error':'Stale revision'}); return + conversations[cid].update(body); conversations[cid]['revision'] += 1; data = {'conversation':conversations[cid]} else: data = {'conversation':conversations[cid],'messages':messages[cid],'runs':[],'next_cursor':None} elif path == '/api/auth/logout': data = {'ok':True} else: @@ -203,3 +208,21 @@ def test_qualified_research_and_private_export(): assert info.value.suggested_filename.endswith('.json') assert all('/api/conversations/' in path for method,path,body in calls if path.endswith('/export')) browser.close() + + +def test_immediate_pin_after_response_uses_final_server_revision(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(); calls, streams, conversations, messages = fixture(page) + 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('#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.locator('#pinConversation').click() + expect(page.locator('#pinConversation')).to_have_text('Unpin') + assert conversations['c1']['pinned'] is True + assert 'Conversation refreshed' not in page.locator('#chatNotice').inner_text() + patch = next(body for method,path,body in calls if method == 'PATCH') + assert patch['revision'] == 2 + browser.close() diff --git a/web/chat.js b/web/chat.js index 50a5392..de407aa 100644 --- a/web/chat.js +++ b/web/chat.js @@ -8,11 +8,11 @@ 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; }; function controls() { - const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || state.voiceActive; + 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)); $('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; + 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; $('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive'; $('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin'; @@ -163,7 +163,7 @@ } catch (error) {if (controller.signal.aborted) return; if (++retries >= 3) {notice('Connection interrupted. Reopen this conversation to resume the response.'); break;}} await new Promise(resolve => {const timer = setTimeout(resolve, 1000); controller.signal.addEventListener('abort', () => {clearTimeout(timer); resolve();}, {once: true});}); } - } finally {if (state.stream === controller) {state.stream = null; $('runProgress').replaceChildren(); if (state.run?.error) notice(state.run.error); controls(); render();}} + } finally {if (state.stream === controller) {if (terminal(state.run) && epoch === state.epoch && !controller.signal.aborted) {try {await refreshSelected(false); await list();} catch (error) {if (epoch === state.epoch) notice('Response saved, but conversation details could not refresh. Reopen the conversation before changing it.');}} if (state.stream !== controller || epoch !== state.epoch) return; state.stream = null; $('runProgress').replaceChildren(); if (state.run?.error) notice(state.run.error); controls(); render();}} } function approval(data, runId) {const area = $('runProgress'); area.replaceChildren(); const text = document.createElement('p'); text.textContent = data.prompt || 'Hope needs your permission to continue.'; area.append(text); for (const decision of ['approve', 'deny']) area.append(button(decision === 'approve' ? 'Approve' : 'Deny', async () => {const result = await api('/api/runs/' + enc(runId) + '/approval', 'POST', {approval_id: data.approval_id, decision}); state.run = result.run; area.replaceChildren(); controls();}));} $('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);} };