fix: give realtime voice a dedicated conversation view
Rebuild & Deploy / rebuild (push) Successful in 3m7s

This commit is contained in:
snowspeeder committed 2026-10-04 10:35:34 -04:00
1 parent 257ab48f0f
commit 8905dc2c2a
9 files changed
+302 -101

No files matched your search

+25
View File
@@ -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)
+3 -1
View File
@@ -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)
+13 -5
View File
@@ -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()
+2
View File
@@ -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()
+132
View File
@@ -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, '<img src=x onerror=alert(1)>')
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()
+32 -29
View File
@@ -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();
}
+49 -6
View File
@@ -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);});
+3 -3
View File
@@ -40,9 +40,9 @@
<div id="conversationActions" class="conversation-actions" popover="auto"><button id="pinConversation" class="btn btn-ghost" disabled>Pin</button><button id="renameConversation" class="btn btn-ghost" disabled>Rename</button><button id="moveConversation" class="btn btn-ghost" disabled>Move</button><button id="exportMarkdown" class="btn btn-ghost">Download Markdown</button><button id="exportJSON" class="btn btn-ghost">Download JSON</button><button id="archiveConversation" class="btn btn-ghost" disabled>Archive</button><button id="deleteConversation" class="btn btn-ghost" disabled>Delete</button></div>
<p id="accountStatus" role="status" class="chat-notice"></p><div id="chatNotice" class="chat-notice" role="status" aria-live="polite"></div>
<section id="legacyVoice" class="inline-voice workspace" hidden aria-labelledby="voiceHeading">
<div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true"></div><div class="voice-summary"><h2 id="voiceHeading">Realtime conversation</h2><p id="stateLabel" aria-live="polite">Ready when you are</p><p id="modeDescription">Speak naturally. You can interrupt Hope.</p><span id="thinkingChip" hidden>Thinking…</span><span id="bargeHint" hidden>Speak to interrupt</span><div id="status" class="status" role="status">Secure connection ready</div><div class="mic-meter" id="micLevel" aria-hidden="true"><span id="micMeterFill"></span></div></div>
<div class="voice-controls"><button id="startBtn" class="btn btn-primary" type="button">Start voice</button><button id="muteBtn" class="btn btn-secondary" aria-label="Mute microphone" title="Mute microphone" disabled>Mute</button><button id="stopBtn" class="btn btn-danger" disabled>End</button><button id="settingsBtn" class="btn btn-ghost" type="button">Voice settings</button></div>
<div id="conversation" class="live-transcript"><h3 id="conversationHeading" class="sr-only">Live transcript</h3><span id="transcriptModeLabel" class="sr-only">Live voice transcript</span><div id="messages" aria-live="polite"></div></div>
<header class="voice-session-header"><div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true"></div><div class="voice-summary"><h2 id="voiceHeading">Voice conversation</h2><p id="stateLabel" aria-live="polite">Ready when you are</p><p id="modeDescription" class="sr-only">Speak naturally. You can interrupt Hope.</p><span id="thinkingChip" hidden>Thinking…</span><span id="bargeHint" hidden>Speak to interrupt</span><div id="status" class="status" role="status">Secure connection ready</div><div class="mic-meter" id="micLevel" aria-hidden="true"><span id="micMeterFill"></span></div></div><button id="settingsBtn" class="btn btn-ghost" type="button">Settings</button></header>
<div id="conversation" class="live-transcript" tabindex="0" role="region" aria-labelledby="conversationHeading"><h3 id="conversationHeading" class="sr-only">Voice transcript</h3><span id="transcriptModeLabel" class="sr-only">Live voice transcript</span><div id="voiceHistory"></div><p id="voiceTranscriptHint" class="voice-transcript-hint">Your live transcript will appear here when you speak.</p><div id="messages" role="log" aria-label="Live voice messages" aria-live="polite" aria-relevant="additions text"></div></div>
<div class="voice-controls"><button id="startBtn" class="btn btn-primary" type="button" hidden>Start voice</button><button id="muteBtn" class="btn btn-secondary" aria-label="Mute microphone" title="Mute microphone" aria-pressed="false" disabled>Mute</button><button id="stopBtn" class="btn btn-danger" type="button" disabled>End voice</button></div>
</section>
<div id="chatTimeline" class="chat-timeline" tabindex="-1" aria-label="Messages"></div><div id="runProgress" class="run-progress" role="status" aria-live="polite"></div>
<form id="primaryComposer" class="primary-composer" aria-label="Message Hope"><label class="sr-only" for="primaryInput">Message Hope</label><textarea id="primaryInput" rows="2" placeholder="What’s on your mind?" required disabled></textarea><div id="attachmentPreviews"></div><div class="composer-footer"><div id="featureActions" class="feature-actions"></div><label id="researchModeLabel" for="researchMode" hidden>Mode</label><select id="researchMode" hidden><option value="normal">Standard</option><option value="research">Research</option><option value="deep_research">Deep research</option></select><div class="composer-right"><span>Talk</span><button id="talkToHope" class="btn lightwell-button" type="button" aria-label="Start realtime voice conversation" aria-controls="legacyVoice" disabled><span class="lightwell-symbol" aria-hidden="true"></span></button><button id="cancelRun" class="btn" type="button" hidden>Stop</button><button id="primarySend" class="btn btn-primary send-button" aria-label="Send message" disabled>↑</button></div></div></form>
+43 -57
View File
@@ -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}
}