diff --git a/QUALIFICATION.md b/QUALIFICATION.md index 0bbf7bd..aab4399 100644 --- a/QUALIFICATION.md +++ b/QUALIFICATION.md @@ -1,5 +1,30 @@ # Multi-user Hermes migration — qualification and operations +## Dedicated web voice session refinement — 2026-10-04 (local only; not deployed) + +Local browser qualification exercised the actual web app's voice start, mute, +unmute, transcript-packet handling and end lifecycle with fake LiveKit at +1440×900, 390×844, 320×568 and 844×390. No JavaScript errors or viewport overflow +were observed. The transcript owns its scroll region, session controls remain +persistent, the text composer is hidden during voice, and its draft is restored +when the session ends. Independent actual-app fake-transport screenshots passed +in light and dark themes at all four viewports after the final changes; the LED +overflow issue was fixed. + +Parent-reported static checks passed again after the final changes: +`node --check web/app.js`, `node --check web/chat.js`, `git diff --check`, and +theme generator `--check`. The final seven-module offline browser suite passed: +**47 passed in 97.40s** (parent receipt `bash-1727`). Coverage includes cancellation +while connecting, token failure, microphone denial, disconnect, logout, scroll +position, selected durable-history isolation, and the HTTP-only compatibility +composer. Fake transport and simulated microphone failures are browser lifecycle +evidence, not physical audio qualification. + +No credentials, live APIs or NAS were accessed for this refinement. No production +deployment, physical microphone, real TTS or native qualification is claimed. +Existing dirty iOS, AGENTS and design changes were left untouched. No commit or +push was performed. + ## Web UX refinement — 2026-10-04 (production deployed) Release `ea4e191` deployed successfully via [Gitea run244](https://git.srmr.xyz/snowspeeder/hope-voice-api/actions/runs/244) diff --git a/tests/unit/test_chat_web.py b/tests/unit/test_chat_web.py index 8ad407a..bd7a108 100644 --- a/tests/unit/test_chat_web.py +++ b/tests/unit/test_chat_web.py @@ -244,7 +244,9 @@ def test_realtime_voice_is_inline_in_same_thread_and_creates_real_conversation() expect(page.locator('#legacyVoice')).to_be_visible() expect(page.locator('#primaryChat')).to_be_visible() expect(page.locator('#primaryInput')).to_have_value('Keep this draft while I talk') - expect(page.locator('#primaryInput')).to_be_enabled() + expect(page.locator('#primaryInput')).to_be_disabled() + expect(page.locator('#primaryComposer')).to_be_hidden() + expect(page.locator('#chatTimeline')).to_be_hidden() 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) diff --git a/tests/unit/test_model_settings_ui.py b/tests/unit/test_model_settings_ui.py index 50a0172..b050af1 100644 --- a/tests/unit/test_model_settings_ui.py +++ b/tests/unit/test_model_settings_ui.py @@ -22,6 +22,8 @@ def test_model_settings_load_save_reload_and_failure(): url = req.request.url if '/api/auth/me' in url: req.fulfill(json={'user':account}) + elif '/api/capabilities' in url: + req.fulfill(json={'version': 1, 'features': {}}) elif '/api/settings/model' in url: calls.append(req.request.method) if req.request.method == 'PUT': @@ -41,7 +43,9 @@ def test_model_settings_load_save_reload_and_failure(): page.goto('https://hope.test/') page.wait_for_load_state('networkidle') assert calls == [] - page.locator('#settingsBtn').click() + page.locator('#drawerOpen').click() + page.locator('#accountMenuBtn').click() + page.locator('[data-settings-target="preferences"]').click() expect(page.locator('#modelSelect')).to_be_enabled() expect(page.locator('#saveModelSettings')).to_be_disabled() page.locator('#modelSelect').select_option('openai/gpt-6.1-sol-subscription') @@ -50,15 +54,19 @@ def test_model_settings_load_save_reload_and_failure(): expect(page.locator('#modelSettingsStatus')).to_contain_text('Saved') assert saved == {'model':'openai/gpt-6.1-sol-subscription','thinking_level':'high'} assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') - page.locator('#closeSheetBtn').click() + page.locator('#settingsClose').click() page.reload(); page.wait_for_load_state('networkidle') - page.locator('#settingsBtn').click() + page.locator('#drawerOpen').click() + page.locator('#accountMenuBtn').click() + page.locator('[data-settings-target="preferences"]').click() expect(page.locator('#modelSelect')).to_have_value(saved['model']) expect(page.locator('#thinkingLevelSelect')).to_have_value('high') page.screenshot(path='/tmp/hope-model-settings-mobile.png',full_page=True) - page.locator('#closeSheetBtn').click() + page.locator('#settingsClose').click() available = False - page.locator('#settingsBtn').click() + page.locator('#drawerOpen').click() + page.locator('#accountMenuBtn').click() + page.locator('[data-settings-target="preferences"]').click() expect(page.locator('#modelSelect')).to_be_disabled() expect(page.locator('#saveModelSettings')).to_be_disabled() expect(page.locator('#retryModelSettings')).to_be_visible() diff --git a/tests/unit/test_sidebar_actions_web.py b/tests/unit/test_sidebar_actions_web.py index a9d7b75..27e0b54 100644 --- a/tests/unit/test_sidebar_actions_web.py +++ b/tests/unit/test_sidebar_actions_web.py @@ -100,6 +100,8 @@ def test_row_menu_keyboard_top_layer_and_selected_voice_safety(): page.keyboard.press('Escape') expect(page.locator('.conversation-row-menu')).not_to_be_visible() page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))") + expect(page.locator('#drawerOpen')).to_have_attribute('aria-expanded', 'false') + page.locator('#drawerOpen').click() active = menu(page, 'A saved thought') for name in ['Pin', 'Rename', 'Archive']: expect(active.get_by_role('button', name=name, exact=True)).to_be_disabled() diff --git a/tests/unit/test_voice_session_web.py b/tests/unit/test_voice_session_web.py new file mode 100644 index 0000000..981a5ce --- /dev/null +++ b/tests/unit/test_voice_session_web.py @@ -0,0 +1,132 @@ +"""Offline dedicated voice shell tests using the real start/stop code and fake SDK.""" +from playwright.sync_api import sync_playwright, expect +from test_chat_web import fixture +from test_voice_settings_ui import VOICE_SDK + + +def transport(page, token_failure=False, connecting=False): + page.route('https://hope.test/livekit-client.umd.js', lambda r: r.fulfill( + body=VOICE_SDK.replace("this.state='connected';const agent", "if(window.holdConnect) await new Promise(resolve=>window.releaseConnect=resolve);this.state='connected';const agent"), + content_type='application/javascript')) + def token(r): + page.evaluate('(body) => window.lastTokenBody=body', r.request.post_data_json) + if token_failure: + r.fulfill(status=503, json={'error': 'Voice unavailable'}) + else: + r.fulfill(json={'token': 'offline-token', 'conversation_id': r.request.post_data_json.get('conversation_id')}) + page.route('https://hope.test/token', token) + if connecting: + page.add_init_script('window.holdConnect=true') + + +def open_thread(page, width): + page.goto('https://hope.test/'); page.wait_for_load_state('networkidle') + if width <= 760: + page.locator('#drawerOpen').click() + page.get_by_role('button', name='A saved thought', exact=True).click() + expect(page.locator('#chatTimeline')).to_contain_text('Original question') + + +def packet(page, text): + page.evaluate("""text => testRoom.emit('DataReceived',new TextEncoder().encode(JSON.stringify({type:'transcript',role:'agent',text})),testRoom.remoteParticipants.get('agent'),null,'transcript')""", text) + + +def test_voice_shell_real_start_mute_scroll_stop_and_saved_history(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + for width, height in [(1440, 900), (390, 844), (320, 568), (844, 390)]: + page = browser.new_page(viewport={'width': width, 'height': height}, reduced_motion='reduce') + errors = []; page.on('pageerror', lambda e: errors.append(str(e))) + calls, _, conversations, messages = fixture(page); transport(page) + page.add_init_script("localStorage.setItem('hope_history:https://hope.test:alice',JSON.stringify([{role:'agent',text:'Unrelated account snapshot'}]))") + open_thread(page, width) + page.locator('#primaryInput').fill('Keep my draft') + page.locator('#talkToHope').click() + page.wait_for_function('HopeVoice.state().ready') + expect(page.locator('#legacyVoice')).to_be_visible() + for selector in ['#primaryComposer', '#chatTimeline', '.chat-footnote']: + expect(page.locator(selector)).to_be_hidden() + expect(page.locator('#primaryInput')).to_be_disabled() + expect(page.locator('#voiceHistory')).to_contain_text('Original question') + assert 'Unrelated account snapshot' not in page.locator('#conversation').inner_text() + page.locator('#muteBtn').click() + expect(page.locator('#muteBtn')).to_have_text('Unmute') + expect(page.locator('#muteBtn')).to_have_attribute('aria-pressed', 'true') + page.locator('#muteBtn').click() + for n in range(12): packet(page, f'Live response {n} ' + 'Readable transcript. ' * 30) + packet(page, '') + assert page.locator('#messages img').count() == 0 + assert page.locator('#voiceHistory').get_by_text('Original question', exact=True).count() == 1 + assert page.evaluate("""() => {const s=document.querySelector('#conversation');return s.scrollHeight>s.clientHeight && s.scrollTop>0;}""") + page.locator('#conversation').evaluate('(el) => el.scrollTop=0') + packet(page, 'Do not jump while reading earlier messages') + assert page.locator('#conversation').evaluate('(el) => el.scrollTop') == 0 + for control in ['#muteBtn', '#stopBtn', '#settingsBtn']: + box = page.locator(control).bounding_box() + assert box and box['y'] >= 0 and box['y'] + box['height'] <= height + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + page.locator('#settingsBtn').click(); expect(page.locator('#settingsDialog')).to_be_visible() + page.locator('#settingsClose').click() + messages['c1'].append(dict(id='v1', role='assistant', content='Actually saved voice reply', status='completed')) + page.locator('#stopBtn').click() + expect(page.locator('#legacyVoice')).to_be_hidden() + expect(page.locator('#chatTimeline')).to_contain_text('Actually saved voice reply') + expect(page.locator('#primaryInput')).to_have_value('Keep my draft') + expect(page.locator('#primaryInput')).to_be_enabled() + expect(page.locator('#talkToHope')).to_be_focused() + assert page.evaluate('window.lastTokenBody') == {'conversation_id': 'c1'} + assert not errors + page.close() + browser.close() + + +def test_compatibility_text_composer_uses_http_and_preserves_primary_draft(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(); calls, _, _, _ = fixture(page); transport(page) + open_thread(page, 1280) + page.locator('#primaryInput').fill('Existing durable draft') + page.evaluate("document.getElementById('chatInput').value='Legacy text';document.getElementById('chatComposer').requestSubmit()") + expect(page.locator('#primaryInput')).to_have_value('Existing durable draft') + assert not any(path.endswith('/messages') or path == '/token' for _, path, _ in calls) + page.locator('#primaryInput').fill('') + page.evaluate("document.getElementById('chatComposer').requestSubmit()") + expect(page.locator('#chatTimeline')).to_contain_text('Legacy text') + assert any(path.endswith('/messages') and body['content'] == 'Legacy text' for _, path, body in calls) + assert page.evaluate('window.lastTokenBody') is None + assert page.evaluate('window.testRoom') is None + browser.close() + + +def test_voice_failure_cancel_disconnect_and_logout_restore_text(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + for mode in ['token-failure', 'mic-failure', 'cancel', 'disconnect', 'logout']: + page = browser.new_page(viewport={'width': 390, 'height': 844}) + fixture(page); transport(page, token_failure=mode == 'token-failure', connecting=mode == 'cancel') + if mode == 'mic-failure': page.add_init_script('window.denyMic=true') + open_thread(page, 390); page.locator('#primaryInput').fill('Draft survives') + page.locator('#talkToHope').click() + if mode == 'cancel': + expect(page.locator('#stopBtn')).to_be_visible() + page.locator('#stopBtn').click() + page.evaluate('window.releaseConnect?.()') + elif mode not in ['token-failure', 'mic-failure']: + page.wait_for_function('HopeVoice.state().ready') + if mode == 'disconnect': page.evaluate("testRoom.emit('Disconnected')") + else: + page.locator('#drawerOpen').click(); page.locator('#accountMenuBtn').click(); page.locator('#logoutBtn').click() + expect(page.locator('#legacyVoice')).to_be_hidden() + page.wait_for_function('!HopeVoice.state().active && !HopeVoice.state().microphone_enabled') + if mode == 'logout': + expect(page.locator('#loginGate')).to_be_visible() + expect(page.locator('#primaryInput')).to_have_value('') + expect(page.locator('#voiceHistory')).to_be_empty() + else: + expect(page.locator('#primaryComposer')).to_be_visible() + expect(page.locator('#primaryInput')).to_be_enabled() + expect(page.locator('#primaryInput')).to_have_value('Draft survives') + if mode.endswith('failure'): expect(page.locator('#chatNotice')).to_contain_text('Could not start voice') + else: expect(page.locator('#chatNotice')).not_to_contain_text('saved') + page.close() + browser.close() diff --git a/web/app.js b/web/app.js index 36eae72..30aa77a 100644 --- a/web/app.js +++ b/web/app.js @@ -34,6 +34,7 @@ let tokenAbort = null; let voiceConversationId = null; let voiceSessionActive = false; let lastVoiceState = ""; +let voiceError = null; function escapeHtml(s) { const d = document.createElement("div"); @@ -140,7 +141,7 @@ async function setConversationMode(mode) { if (mode === "voice" && previousMode === "text") { chatInput.blur(); window.setTimeout(() => { - document.querySelector(".workspace").scrollIntoView({ block: "start", behavior: "smooth" }); + document.querySelector(".workspace").scrollIntoView({ block: "start", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); }, 0); } @@ -470,7 +471,15 @@ function formatTimestamp(ts) { return new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); } -function renderMessage(msg) { +function transcriptFollows() { + const transcript = document.getElementById("conversation"); + return transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 100; +} +function scrollTranscript(follow) { + document.getElementById("voiceTranscriptHint").hidden = !!messagesEl.children.length; + if (follow) {const transcript = document.getElementById("conversation"); transcript.scrollTop = transcript.scrollHeight;} +} +function renderMessage(msg, follow = transcriptFollows()) { const wrap = document.createElement("div"); wrap.className = `message-row ${msg.role}`; wrap.dataset.ts = formatTimestamp(msg.ts); @@ -492,26 +501,28 @@ function renderMessage(msg) { } wrap.appendChild(bubble); messagesEl.appendChild(wrap); - messagesEl.scrollTop = messagesEl.scrollHeight; + scrollTranscript(follow); } function addMessage(role, text) { + const follow = transcriptFollows(); clearPartials(); const msg = { role, text, ts: Date.now() }; history.push(msg); persistHistory(); - renderMessage(msg); + renderMessage(msg, follow); return msg; } // Partial transcripts render greyed and update in place until a final arrives. function addPartial(role, text) { + const follow = transcriptFollows(); const rows = messagesEl.querySelectorAll(".message-row.partial"); let last = null; for (const r of rows) last = r; if (last && last.dataset.role === role) { last.querySelector(".bubble").textContent = cleanSpokenText(text); - messagesEl.scrollTop = messagesEl.scrollHeight; + scrollTranscript(follow); return; } const wrap = document.createElement("div"); @@ -527,7 +538,7 @@ function addPartial(role, text) { bubble.textContent = cleanSpokenText(text); wrap.appendChild(bubble); messagesEl.appendChild(wrap); - messagesEl.scrollTop = messagesEl.scrollHeight; + scrollTranscript(follow); } function clearPartials() { @@ -536,8 +547,8 @@ function clearPartials() { function renderHistory() { messagesEl.innerHTML = ""; - for (const msg of history) renderMessage(msg); - messagesEl.scrollTop = messagesEl.scrollHeight; + for (const msg of history) renderMessage(msg, false); + scrollTranscript(true); } clearBtn.addEventListener("click", () => { @@ -1062,7 +1073,7 @@ function voiceState() { const ready = agent_connected && agentReady && microphone_enabled && !micMuted; return { active: voiceSessionActive, state: voiceSessionActive ? (ready ? uiState : (agent_connected && agentReady && micMuted ? "muted" : "connecting")) : "idle", ready, muted: micMuted, connected, agent_connected, microphone_enabled, - conversation_id: voiceConversationId, cid: voiceConversationId }; + conversation_id: voiceConversationId, cid: voiceConversationId, error: voiceError }; } function publishVoiceState() { const detail = voiceState(); @@ -1112,6 +1123,7 @@ async function connectConversation() { const attempt = ++connectionEpoch; if (window.HopeChat?.running) throw new Error('Wait for the current response or stop it before starting voice.'); voiceConversationId = window.HopeChat?.conversationId || null; + voiceError = null; history = []; messagesEl.replaceChildren(); scrollTranscript(true); voiceSessionActive = true; publishVoiceState(); startBtn.disabled = true; stopBtn.disabled = false; const promise = (async () => { @@ -1139,7 +1151,7 @@ async function connectConversation() { } muteBtn.disabled = conversationMode !== "voice"; updateMuteIcon(); setState("listening"); return room; } catch (error) { - if (attempt === connectionEpoch) { cleanupRoom(); statusEl.textContent = `Error: ${error.message}`; setState("idle"); startBtn.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; stopBtn.disabled = true; } + if (attempt === connectionEpoch) { voiceError = error.message; cleanupRoom(); statusEl.textContent = `Error: ${error.message}`; setState("idle"); startBtn.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; stopBtn.disabled = true; } throw error; } finally { if (attempt === connectionEpoch) connectionPromise = null; } })(); @@ -1181,24 +1193,13 @@ chatComposer.addEventListener("submit", async (event) => { const text = chatInput.value.trim(); if (!text || !HopeAuth.user || HopeAuth.user.status !== "ready") return; - sendChatBtn.disabled = true; - chatInput.disabled = true; - try { - if (conversationMode !== "text") await setConversationMode("text"); - await connectConversation(); - setState("thinking"); - await room.localParticipant.sendText(text, { topic: "lk.chat" }); - chatInput.value = ""; - resizeChatInput(); - } catch (err) { - console.error("Text message failed:", err); - statusEl.textContent = `Message not sent: ${err.message}`; - setState(room ? "listening" : "idle"); - } finally { - chatInput.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; - sendChatBtn.disabled = !HopeAuth.user || !chatInput.value.trim(); - chatInput.focus(); - } + // Compatibility composer delegates to durable HTTP chat, never voice transport. + const input = document.getElementById("primaryInput"); + if (voiceSessionActive || input.disabled || input.value.trim()) return; + input.value = text; + input.dispatchEvent(new Event("input", {bubbles:true})); + document.getElementById("primaryComposer").requestSubmit(); + chatInput.value = ""; resizeChatInput(); }); // ── Stop ──────────────────────────────────────────────────────────────────── @@ -1225,6 +1226,8 @@ muteBtn.addEventListener("click", toggleMute); function updateMuteIcon() { publishVoiceState(); const muted = muteBtn.classList.toggle("muted", micMuted); + muteBtn.textContent = muted ? "Unmute" : "Mute"; + muteBtn.setAttribute("aria-pressed", String(muted)); muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone"); muteBtn.title = muted ? "Unmute microphone" : "Mute microphone"; } @@ -1274,7 +1277,7 @@ HopeAuth.onChange((user, changed) => { voiceSearchEl.value = ""; if (user) loadVoiceCatalog(); historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null; - history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true; + history = []; renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true; chatInput.value = ""; sendChatBtn.disabled = true; setTasksOpen(false); closeSheet(); } diff --git a/web/chat.js b/web/chat.js index fe73a7a..fd34aa3 100644 --- a/web/chat.js +++ b/web/chat.js @@ -12,10 +12,10 @@ 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)); + $('primaryInput').disabled = !ready || state.busy || !terminal(state.run) || !!state.stream || state.voiceActive; $('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); + for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'exportMarkdown', 'exportJSON', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy || !!state.stream || !terminal(state.run) || state.voiceActive; $('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'; @@ -26,7 +26,51 @@ window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}})); controls(); } - window.addEventListener('hope:voice-state', event => {const wasActive = state.voiceActive; state.voiceActive = !!event.detail?.active; controls(); if (state.voiceActive) notice('Voice is active. End the voice conversation before sending text.'); else {if (wasActive) notice(''); if (wasActive && HopeAuth.user && state.conversation) refreshSelected().then(() => {notice('Voice conversation saved.'); list();}).catch(error => notice(error.message));}}); + let voiceReturnFocus = null; + function voiceSurface(active) { + const wasActive = $('primaryChat').dataset.voiceActive === 'true'; + if (active && !wasActive) { + voiceReturnFocus = document.activeElement; + const history = $('voiceHistory'); history.replaceChildren(); + // Snapshot only this selected durable thread; live turns are separate until refreshed on stop. + for (const message of state.messages) { + const row = document.createElement('article'); row.className = `chat-message ${message.role}`; + const name = document.createElement('h2'); name.textContent = message.role === 'user' ? 'You' : 'Hope'; + row.append(name, markdown(message.content)); history.append(row); + } + } + $('primaryChat').dataset.voiceActive = String(active); + $('legacyVoice').hidden = !active; + for (const id of ['chatTimeline', 'primaryComposer', 'runProgress']) {$(id).hidden = active; $(id).inert = active;} + document.querySelector('.chat-footnote').hidden = active; + document.querySelector('.skip-link').href = active ? '#conversation' : '#chatTimeline'; + if (active && !wasActive) { + if ($('chatSidebar').classList.contains('drawer-open')) drawer(false); + $('conversationActions').matches(':popover-open') && $('conversationActions').hidePopover(); + $('stopBtn').disabled = false; $('stopBtn').focus({preventScroll:true}); + $('conversation').scrollTop = $('conversation').scrollHeight; + } else if (!active && wasActive && HopeAuth.user) { + if ($('legacyVoice').contains(document.activeElement) || document.activeElement === document.body) { + const target = voiceReturnFocus?.isConnected && voiceReturnFocus.getClientRects().length && !voiceReturnFocus.closest('[inert]') && !voiceReturnFocus.disabled ? voiceReturnFocus : $('talkToHope'); + target.focus({preventScroll:true}); + } + voiceReturnFocus = null; + } + } + window.addEventListener('hope:voice-state', event => { + const wasActive = state.voiceActive; state.voiceActive = !!event.detail?.active; + voiceSurface(state.voiceActive); controls(); + if (state.voiceActive) notice(''); + else if (wasActive) { + const epoch = state.epoch, cid = state.conversation?.id, before = new Set(state.messages.map(m => m.id)); + notice(event.detail?.error ? 'Could not start voice: ' + event.detail.error : 'Voice ended. Refreshing conversation…'); + if (HopeAuth.user && cid) refreshSelected().then(() => { + if (epoch !== state.epoch || cid !== state.conversation?.id || state.voiceActive) return; + if (!event.detail?.error) notice(state.messages.some(m => !before.has(m.id)) ? 'Voice conversation saved. History refreshed.' : 'Voice ended. History refreshed.'); + return list(); + }).catch(error => {if (epoch === state.epoch && !state.voiceActive) notice('Could not refresh voice history: ' + error.message);}); + } + }); window.addEventListener('hope:attachments-changed', controls); window.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}}; function stopStream() { state.stream?.abort(); state.stream = null; } @@ -42,13 +86,12 @@ 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(); async function startVoice() { - if (state.voiceActive) { $('legacyVoice').hidden = false; $('muteBtn').focus(); return; } + if (state.voiceActive) { $('stopBtn').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();}} } @@ -260,7 +303,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) {++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'});} + 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; state.voiceActive = false; voiceSurface(false); $('voiceHistory').replaceChildren(); $('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 951f444..ef55425 100644 --- a/web/index.html +++ b/web/index.html @@ -40,9 +40,9 @@

diff --git a/web/style.css b/web/style.css index d9f1b6e..332ea8e 100644 --- a/web/style.css +++ b/web/style.css @@ -525,30 +525,28 @@ input::placeholder,textarea::placeholder{ object-fit:cover; border-radius:6px} -.inline-voice{ - flex-shrink:0; - display:grid; - grid-template-columns:84px minmax(0,1fr) auto; - align-items:center; - gap:16px; - padding:16px 24px; - border-bottom:1px solid var(--line); - background:var(--hope-persona-surface); - max-height:38dvh; - overflow:auto} +.inline-voice{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:var(--canvas)} +.voice-session-header{display:flex;align-items:center;gap:14px;padding:14px max(24px,calc((100% - 740px)/2));border-bottom:1px solid var(--line);background:var(--hope-persona-surface);flex-shrink:0} +.voice-session-header .led-wall{width:48px;height:48px;flex-shrink:0} +.voice-summary{flex:1;min-width:0;overflow-wrap:anywhere} +.primary-chat[data-voice-active="true"]>#chatNotice{display:none} .led-wall{ width:84px; height:84px; display:grid; - grid-template-columns:repeat(21,1fr); - grid-template-rows:repeat(21,1fr); + grid-template-columns:repeat(21,minmax(0,1fr)); + grid-template-rows:repeat(21,minmax(0,1fr)); + overflow:hidden; place-items:center; padding:4px; color:var(--accent); border-radius:12px} .led-dot{ - width:var(--sz,3px); - height:var(--sz,3px); + width:100%; + height:100%; + max-width:3px; + max-height:3px; + padding:0; min-width:1px; min-height:1px; border:0; @@ -568,16 +566,8 @@ input::placeholder,textarea::placeholder{ line-height:1.6; color:var(--ink-soft); margin-top:4px} -.voice-controls{ - display:flex; - flex-wrap:wrap; - gap:6px; - justify-content:flex-end; - max-width:240px} -.voice-controls .btn{ - font-size:12px} -.voice-controls #startBtn:disabled,.voice-controls #stopBtn:disabled{ - display:none} +.voice-controls{display:flex;gap:12px;justify-content:center;flex-shrink:0;padding:14px 16px max(14px,env(safe-area-inset-bottom));border-top:1px solid var(--line);background:var(--hope-persona-surface)} +.voice-controls .btn{min-width:112px} .mic-meter{ height:2px; max-width:170px; @@ -588,20 +578,19 @@ input::placeholder,textarea::placeholder{ height:100%; width:0; background:var(--accent)} -.live-transcript{ - grid-column:2/-1; - font-size:13px; - line-height:1.65} -.live-transcript #messages:empty{ - display:none} -.message-row{ - margin:10px 0; - overflow-wrap:anywhere} -.message-row.user{ - font-weight:600} -.message-row.system{ - color:var(--ink-soft); - font-size:11px} +.live-transcript{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;padding:28px max(24px,calc((100% - 740px)/2));scrollbar-gutter:stable;font-size:15px;line-height:1.8} +.voice-transcript-hint{color:var(--ink-soft);max-width:74ch;margin:12px 0} +#voiceHistory:not(:empty){border-bottom:1px solid var(--line);margin-bottom:24px} +#voiceHistory .message-actions{display:none} +.message-row{max-width:74ch;margin:0 0 24px;overflow-wrap:anywhere} +.message-row:before{content:"Hope";display:block;font-size:13px;font-weight:600;margin-bottom:8px} +.message-row.user{margin-left:auto;max-width:85%} +.message-row.user:before{content:"You";color:var(--ink-soft)} +.message-row.user .bubble{background:var(--hope-selected-wash);padding:14px 18px;border-radius:12px} +.message-row .bubble{white-space:pre-wrap} +.message-row.partial .bubble{color:var(--ink-soft)} +.message-row.system{color:var(--ink-soft);font-size:13px} +.message-row.system:before{content:none} .audio-viz{ display:none} @@ -1085,24 +1074,12 @@ dialog p{ #researchMode{ max-width:90px; font-size:11px} -.inline-voice{ - grid-template-columns:64px minmax(0,1fr); - gap:10px; - padding:12px 16px; - max-height:40dvh} -.led-wall{ - width:64px; - height:64px} -.voice-controls{ - grid-column:1/-1; - max-width:none; - justify-content:flex-start} -.live-transcript{ - grid-column:1/-1} -.voice-summary #stateLabel{ - font-size:16px} -.voice-summary #modeDescription{ - display:none} +.voice-session-header{padding:12px 16px;gap:10px} +.voice-session-header .led-wall{width:36px;height:36px} +.voice-session-header #settingsBtn{padding:9px 8px;font-size:12px} +.voice-summary #stateLabel{font-size:16px} +.live-transcript{padding:22px 18px;font-size:14px} +.message-row.user{max-width:95%} .settings-window>.dialog-head{ padding:18px} .settings-layout{ @@ -1164,3 +1141,12 @@ dialog p{ gap:4px} } + +@media(max-height:480px){ + .chat-sidebar nav{flex:1 0 auto;min-height:max-content;overflow:visible} + .primary-chat[data-voice-active="true"] .chat-topbar{min-height:54px;padding-block:5px} + .voice-session-header{padding-block:6px} + .voice-summary h2,.voice-summary #bargeHint,.voice-summary #thinkingChip,.mic-meter{display:none} + .voice-controls{padding-block:6px} + .live-transcript{padding-block:16px} +}