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 @@
Ready when you are
Speak naturally. You can interrupt Hope.
Thinking…Speak to interruptReady when you are
Speak naturally. You can interrupt Hope.
Thinking…Speak to interruptYour live transcript will appear here when you speak.