fix: give realtime voice a dedicated conversation view
Rebuild & Deploy / rebuild (push) Successful in 3m7s
Rebuild & Deploy / rebuild (push) Successful in 3m7s
This commit is contained in:
1 parent
257ab48f0f
commit
8905dc2c2a
9 files changed
+302
-101
No files matched your search
@@ -1,5 +1,30 @@
|
|||||||
# Multi-user Hermes migration — qualification and operations
|
# 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)
|
## 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)
|
Release `ea4e191` deployed successfully via [Gitea run244](https://git.srmr.xyz/snowspeeder/hope-voice-api/actions/runs/244)
|
||||||
|
|||||||
@@ -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('#legacyVoice')).to_be_visible()
|
||||||
expect(page.locator('#primaryChat')).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_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()
|
expect(page.locator('#primarySend')).to_be_disabled()
|
||||||
assert page.evaluate('window.startedVoice') == 'c2'
|
assert page.evaluate('window.startedVoice') == 'c2'
|
||||||
assert any(method == 'POST' and path == '/api/conversations' for method,path,body in calls)
|
assert any(method == 'POST' and path == '/api/conversations' for method,path,body in calls)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ def test_model_settings_load_save_reload_and_failure():
|
|||||||
url = req.request.url
|
url = req.request.url
|
||||||
if '/api/auth/me' in url:
|
if '/api/auth/me' in url:
|
||||||
req.fulfill(json={'user':account})
|
req.fulfill(json={'user':account})
|
||||||
|
elif '/api/capabilities' in url:
|
||||||
|
req.fulfill(json={'version': 1, 'features': {}})
|
||||||
elif '/api/settings/model' in url:
|
elif '/api/settings/model' in url:
|
||||||
calls.append(req.request.method)
|
calls.append(req.request.method)
|
||||||
if req.request.method == 'PUT':
|
if req.request.method == 'PUT':
|
||||||
@@ -41,7 +43,9 @@ def test_model_settings_load_save_reload_and_failure():
|
|||||||
page.goto('https://hope.test/')
|
page.goto('https://hope.test/')
|
||||||
page.wait_for_load_state('networkidle')
|
page.wait_for_load_state('networkidle')
|
||||||
assert calls == []
|
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('#modelSelect')).to_be_enabled()
|
||||||
expect(page.locator('#saveModelSettings')).to_be_disabled()
|
expect(page.locator('#saveModelSettings')).to_be_disabled()
|
||||||
page.locator('#modelSelect').select_option('openai/gpt-6.1-sol-subscription')
|
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')
|
expect(page.locator('#modelSettingsStatus')).to_contain_text('Saved')
|
||||||
assert saved == {'model':'openai/gpt-6.1-sol-subscription','thinking_level':'high'}
|
assert saved == {'model':'openai/gpt-6.1-sol-subscription','thinking_level':'high'}
|
||||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||||
page.locator('#closeSheetBtn').click()
|
page.locator('#settingsClose').click()
|
||||||
page.reload(); page.wait_for_load_state('networkidle')
|
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('#modelSelect')).to_have_value(saved['model'])
|
||||||
expect(page.locator('#thinkingLevelSelect')).to_have_value('high')
|
expect(page.locator('#thinkingLevelSelect')).to_have_value('high')
|
||||||
page.screenshot(path='/tmp/hope-model-settings-mobile.png',full_page=True)
|
page.screenshot(path='/tmp/hope-model-settings-mobile.png',full_page=True)
|
||||||
page.locator('#closeSheetBtn').click()
|
page.locator('#settingsClose').click()
|
||||||
available = False
|
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('#modelSelect')).to_be_disabled()
|
||||||
expect(page.locator('#saveModelSettings')).to_be_disabled()
|
expect(page.locator('#saveModelSettings')).to_be_disabled()
|
||||||
expect(page.locator('#retryModelSettings')).to_be_visible()
|
expect(page.locator('#retryModelSettings')).to_be_visible()
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ def test_row_menu_keyboard_top_layer_and_selected_voice_safety():
|
|||||||
page.keyboard.press('Escape')
|
page.keyboard.press('Escape')
|
||||||
expect(page.locator('.conversation-row-menu')).not_to_be_visible()
|
expect(page.locator('.conversation-row-menu')).not_to_be_visible()
|
||||||
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))")
|
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')
|
active = menu(page, 'A saved thought')
|
||||||
for name in ['Pin', 'Rename', 'Archive']:
|
for name in ['Pin', 'Rename', 'Archive']:
|
||||||
expect(active.get_by_role('button', name=name, exact=True)).to_be_disabled()
|
expect(active.get_by_role('button', name=name, exact=True)).to_be_disabled()
|
||||||
|
|||||||
@@ -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
@@ -34,6 +34,7 @@ let tokenAbort = null;
|
|||||||
let voiceConversationId = null;
|
let voiceConversationId = null;
|
||||||
let voiceSessionActive = false;
|
let voiceSessionActive = false;
|
||||||
let lastVoiceState = "";
|
let lastVoiceState = "";
|
||||||
|
let voiceError = null;
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
@@ -140,7 +141,7 @@ async function setConversationMode(mode) {
|
|||||||
if (mode === "voice" && previousMode === "text") {
|
if (mode === "voice" && previousMode === "text") {
|
||||||
chatInput.blur();
|
chatInput.blur();
|
||||||
window.setTimeout(() => {
|
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);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +471,15 @@ function formatTimestamp(ts) {
|
|||||||
return new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
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");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = `message-row ${msg.role}`;
|
wrap.className = `message-row ${msg.role}`;
|
||||||
wrap.dataset.ts = formatTimestamp(msg.ts);
|
wrap.dataset.ts = formatTimestamp(msg.ts);
|
||||||
@@ -492,26 +501,28 @@ function renderMessage(msg) {
|
|||||||
}
|
}
|
||||||
wrap.appendChild(bubble);
|
wrap.appendChild(bubble);
|
||||||
messagesEl.appendChild(wrap);
|
messagesEl.appendChild(wrap);
|
||||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
scrollTranscript(follow);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMessage(role, text) {
|
function addMessage(role, text) {
|
||||||
|
const follow = transcriptFollows();
|
||||||
clearPartials();
|
clearPartials();
|
||||||
const msg = { role, text, ts: Date.now() };
|
const msg = { role, text, ts: Date.now() };
|
||||||
history.push(msg);
|
history.push(msg);
|
||||||
persistHistory();
|
persistHistory();
|
||||||
renderMessage(msg);
|
renderMessage(msg, follow);
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Partial transcripts render greyed and update in place until a final arrives.
|
// Partial transcripts render greyed and update in place until a final arrives.
|
||||||
function addPartial(role, text) {
|
function addPartial(role, text) {
|
||||||
|
const follow = transcriptFollows();
|
||||||
const rows = messagesEl.querySelectorAll(".message-row.partial");
|
const rows = messagesEl.querySelectorAll(".message-row.partial");
|
||||||
let last = null;
|
let last = null;
|
||||||
for (const r of rows) last = r;
|
for (const r of rows) last = r;
|
||||||
if (last && last.dataset.role === role) {
|
if (last && last.dataset.role === role) {
|
||||||
last.querySelector(".bubble").textContent = cleanSpokenText(text);
|
last.querySelector(".bubble").textContent = cleanSpokenText(text);
|
||||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
scrollTranscript(follow);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
@@ -527,7 +538,7 @@ function addPartial(role, text) {
|
|||||||
bubble.textContent = cleanSpokenText(text);
|
bubble.textContent = cleanSpokenText(text);
|
||||||
wrap.appendChild(bubble);
|
wrap.appendChild(bubble);
|
||||||
messagesEl.appendChild(wrap);
|
messagesEl.appendChild(wrap);
|
||||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
scrollTranscript(follow);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPartials() {
|
function clearPartials() {
|
||||||
@@ -536,8 +547,8 @@ function clearPartials() {
|
|||||||
|
|
||||||
function renderHistory() {
|
function renderHistory() {
|
||||||
messagesEl.innerHTML = "";
|
messagesEl.innerHTML = "";
|
||||||
for (const msg of history) renderMessage(msg);
|
for (const msg of history) renderMessage(msg, false);
|
||||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
scrollTranscript(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearBtn.addEventListener("click", () => {
|
clearBtn.addEventListener("click", () => {
|
||||||
@@ -1062,7 +1073,7 @@ function voiceState() {
|
|||||||
const ready = agent_connected && agentReady && microphone_enabled && !micMuted;
|
const ready = agent_connected && agentReady && microphone_enabled && !micMuted;
|
||||||
return { active: voiceSessionActive, state: voiceSessionActive ? (ready ? uiState : (agent_connected && agentReady && micMuted ? "muted" : "connecting")) : "idle",
|
return { active: voiceSessionActive, state: voiceSessionActive ? (ready ? uiState : (agent_connected && agentReady && micMuted ? "muted" : "connecting")) : "idle",
|
||||||
ready, muted: micMuted, connected, agent_connected, microphone_enabled,
|
ready, muted: micMuted, connected, agent_connected, microphone_enabled,
|
||||||
conversation_id: voiceConversationId, cid: voiceConversationId };
|
conversation_id: voiceConversationId, cid: voiceConversationId, error: voiceError };
|
||||||
}
|
}
|
||||||
function publishVoiceState() {
|
function publishVoiceState() {
|
||||||
const detail = voiceState();
|
const detail = voiceState();
|
||||||
@@ -1112,6 +1123,7 @@ async function connectConversation() {
|
|||||||
const attempt = ++connectionEpoch;
|
const attempt = ++connectionEpoch;
|
||||||
if (window.HopeChat?.running) throw new Error('Wait for the current response or stop it before starting voice.');
|
if (window.HopeChat?.running) throw new Error('Wait for the current response or stop it before starting voice.');
|
||||||
voiceConversationId = window.HopeChat?.conversationId || null;
|
voiceConversationId = window.HopeChat?.conversationId || null;
|
||||||
|
voiceError = null; history = []; messagesEl.replaceChildren(); scrollTranscript(true);
|
||||||
voiceSessionActive = true; publishVoiceState();
|
voiceSessionActive = true; publishVoiceState();
|
||||||
startBtn.disabled = true; stopBtn.disabled = false;
|
startBtn.disabled = true; stopBtn.disabled = false;
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
@@ -1139,7 +1151,7 @@ async function connectConversation() {
|
|||||||
}
|
}
|
||||||
muteBtn.disabled = conversationMode !== "voice"; updateMuteIcon(); setState("listening"); return room;
|
muteBtn.disabled = conversationMode !== "voice"; updateMuteIcon(); setState("listening"); return room;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
} finally { if (attempt === connectionEpoch) connectionPromise = null; }
|
} finally { if (attempt === connectionEpoch) connectionPromise = null; }
|
||||||
})();
|
})();
|
||||||
@@ -1181,24 +1193,13 @@ chatComposer.addEventListener("submit", async (event) => {
|
|||||||
const text = chatInput.value.trim();
|
const text = chatInput.value.trim();
|
||||||
if (!text || !HopeAuth.user || HopeAuth.user.status !== "ready") return;
|
if (!text || !HopeAuth.user || HopeAuth.user.status !== "ready") return;
|
||||||
|
|
||||||
sendChatBtn.disabled = true;
|
// Compatibility composer delegates to durable HTTP chat, never voice transport.
|
||||||
chatInput.disabled = true;
|
const input = document.getElementById("primaryInput");
|
||||||
try {
|
if (voiceSessionActive || input.disabled || input.value.trim()) return;
|
||||||
if (conversationMode !== "text") await setConversationMode("text");
|
input.value = text;
|
||||||
await connectConversation();
|
input.dispatchEvent(new Event("input", {bubbles:true}));
|
||||||
setState("thinking");
|
document.getElementById("primaryComposer").requestSubmit();
|
||||||
await room.localParticipant.sendText(text, { topic: "lk.chat" });
|
chatInput.value = ""; resizeChatInput();
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Stop ────────────────────────────────────────────────────────────────────
|
// ── Stop ────────────────────────────────────────────────────────────────────
|
||||||
@@ -1225,6 +1226,8 @@ muteBtn.addEventListener("click", toggleMute);
|
|||||||
function updateMuteIcon() {
|
function updateMuteIcon() {
|
||||||
publishVoiceState();
|
publishVoiceState();
|
||||||
const muted = muteBtn.classList.toggle("muted", micMuted);
|
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.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone");
|
||||||
muteBtn.title = muted ? "Unmute microphone" : "Mute microphone";
|
muteBtn.title = muted ? "Unmute microphone" : "Mute microphone";
|
||||||
}
|
}
|
||||||
@@ -1274,7 +1277,7 @@ HopeAuth.onChange((user, changed) => {
|
|||||||
voiceSearchEl.value = "";
|
voiceSearchEl.value = "";
|
||||||
if (user) loadVoiceCatalog();
|
if (user) loadVoiceCatalog();
|
||||||
historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null;
|
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;
|
chatInput.value = ""; sendChatBtn.disabled = true;
|
||||||
setTasksOpen(false); closeSheet();
|
setTasksOpen(false); closeSheet();
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-6
@@ -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; };
|
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() {
|
function controls() {
|
||||||
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || !!state.stream || state.voiceActive;
|
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();
|
$('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim();
|
||||||
$('cancelRun').hidden = terminal(state.run); $('researchMode').disabled = !ready || running || state.busy;
|
$('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;
|
$('talkToHope').disabled = !ready || state.busy || !!state.stream || !terminal(state.run); $('conversationMenu').hidden = !state.conversation;
|
||||||
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
|
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
|
||||||
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
|
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
|
||||||
@@ -26,7 +26,51 @@
|
|||||||
window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}}));
|
window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}}));
|
||||||
controls();
|
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.addEventListener('hope:attachments-changed', controls);
|
||||||
window.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}};
|
window.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}};
|
||||||
function stopStream() { state.stream?.abort(); state.stream = null; }
|
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; } }
|
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();
|
mobile.addEventListener('change', adapt); adapt();
|
||||||
async function startVoice() {
|
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;
|
if (state.busy || state.stream || !terminal(state.run) || HopeAuth.user?.status !== 'ready') return;
|
||||||
const epoch = state.epoch; state.busy = true; controls();
|
const epoch = state.epoch; state.busy = true; controls();
|
||||||
try {
|
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 (!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;
|
if (epoch !== state.epoch) return;
|
||||||
$('legacyVoice').hidden = false;
|
|
||||||
window.dispatchEvent(new CustomEvent('hope:start-voice', {detail: {conversation_id: state.conversation.id}}));
|
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();}}
|
} 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;}}
|
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();});
|
document.addEventListener('visibilitychange', () => {if (!document.hidden) sync();});
|
||||||
HopeAuth.onChange((user, changed) => {
|
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;
|
controls(); if (!user || !changed) return;
|
||||||
const epoch = state.epoch; syncTimer = setInterval(sync, 5000);
|
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);});
|
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
@@ -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>
|
<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>
|
<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">
|
<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>
|
<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 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" 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 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>
|
<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>
|
</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>
|
<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>
|
<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
@@ -525,30 +525,28 @@ input::placeholder,textarea::placeholder{
|
|||||||
object-fit:cover;
|
object-fit:cover;
|
||||||
border-radius:6px}
|
border-radius:6px}
|
||||||
|
|
||||||
.inline-voice{
|
.inline-voice{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:var(--canvas)}
|
||||||
flex-shrink:0;
|
.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}
|
||||||
display:grid;
|
.voice-session-header .led-wall{width:48px;height:48px;flex-shrink:0}
|
||||||
grid-template-columns:84px minmax(0,1fr) auto;
|
.voice-summary{flex:1;min-width:0;overflow-wrap:anywhere}
|
||||||
align-items:center;
|
.primary-chat[data-voice-active="true"]>#chatNotice{display:none}
|
||||||
gap:16px;
|
|
||||||
padding:16px 24px;
|
|
||||||
border-bottom:1px solid var(--line);
|
|
||||||
background:var(--hope-persona-surface);
|
|
||||||
max-height:38dvh;
|
|
||||||
overflow:auto}
|
|
||||||
.led-wall{
|
.led-wall{
|
||||||
width:84px;
|
width:84px;
|
||||||
height:84px;
|
height:84px;
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns:repeat(21,1fr);
|
grid-template-columns:repeat(21,minmax(0,1fr));
|
||||||
grid-template-rows:repeat(21,1fr);
|
grid-template-rows:repeat(21,minmax(0,1fr));
|
||||||
|
overflow:hidden;
|
||||||
place-items:center;
|
place-items:center;
|
||||||
padding:4px;
|
padding:4px;
|
||||||
color:var(--accent);
|
color:var(--accent);
|
||||||
border-radius:12px}
|
border-radius:12px}
|
||||||
.led-dot{
|
.led-dot{
|
||||||
width:var(--sz,3px);
|
width:100%;
|
||||||
height:var(--sz,3px);
|
height:100%;
|
||||||
|
max-width:3px;
|
||||||
|
max-height:3px;
|
||||||
|
padding:0;
|
||||||
min-width:1px;
|
min-width:1px;
|
||||||
min-height:1px;
|
min-height:1px;
|
||||||
border:0;
|
border:0;
|
||||||
@@ -568,16 +566,8 @@ input::placeholder,textarea::placeholder{
|
|||||||
line-height:1.6;
|
line-height:1.6;
|
||||||
color:var(--ink-soft);
|
color:var(--ink-soft);
|
||||||
margin-top:4px}
|
margin-top:4px}
|
||||||
.voice-controls{
|
.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)}
|
||||||
display:flex;
|
.voice-controls .btn{min-width:112px}
|
||||||
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}
|
|
||||||
.mic-meter{
|
.mic-meter{
|
||||||
height:2px;
|
height:2px;
|
||||||
max-width:170px;
|
max-width:170px;
|
||||||
@@ -588,20 +578,19 @@ input::placeholder,textarea::placeholder{
|
|||||||
height:100%;
|
height:100%;
|
||||||
width:0;
|
width:0;
|
||||||
background:var(--accent)}
|
background:var(--accent)}
|
||||||
.live-transcript{
|
.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}
|
||||||
grid-column:2/-1;
|
.voice-transcript-hint{color:var(--ink-soft);max-width:74ch;margin:12px 0}
|
||||||
font-size:13px;
|
#voiceHistory:not(:empty){border-bottom:1px solid var(--line);margin-bottom:24px}
|
||||||
line-height:1.65}
|
#voiceHistory .message-actions{display:none}
|
||||||
.live-transcript #messages:empty{
|
.message-row{max-width:74ch;margin:0 0 24px;overflow-wrap:anywhere}
|
||||||
display:none}
|
.message-row:before{content:"Hope";display:block;font-size:13px;font-weight:600;margin-bottom:8px}
|
||||||
.message-row{
|
.message-row.user{margin-left:auto;max-width:85%}
|
||||||
margin:10px 0;
|
.message-row.user:before{content:"You";color:var(--ink-soft)}
|
||||||
overflow-wrap:anywhere}
|
.message-row.user .bubble{background:var(--hope-selected-wash);padding:14px 18px;border-radius:12px}
|
||||||
.message-row.user{
|
.message-row .bubble{white-space:pre-wrap}
|
||||||
font-weight:600}
|
.message-row.partial .bubble{color:var(--ink-soft)}
|
||||||
.message-row.system{
|
.message-row.system{color:var(--ink-soft);font-size:13px}
|
||||||
color:var(--ink-soft);
|
.message-row.system:before{content:none}
|
||||||
font-size:11px}
|
|
||||||
.audio-viz{
|
.audio-viz{
|
||||||
display:none}
|
display:none}
|
||||||
|
|
||||||
@@ -1085,24 +1074,12 @@ dialog p{
|
|||||||
#researchMode{
|
#researchMode{
|
||||||
max-width:90px;
|
max-width:90px;
|
||||||
font-size:11px}
|
font-size:11px}
|
||||||
.inline-voice{
|
.voice-session-header{padding:12px 16px;gap:10px}
|
||||||
grid-template-columns:64px minmax(0,1fr);
|
.voice-session-header .led-wall{width:36px;height:36px}
|
||||||
gap:10px;
|
.voice-session-header #settingsBtn{padding:9px 8px;font-size:12px}
|
||||||
padding:12px 16px;
|
.voice-summary #stateLabel{font-size:16px}
|
||||||
max-height:40dvh}
|
.live-transcript{padding:22px 18px;font-size:14px}
|
||||||
.led-wall{
|
.message-row.user{max-width:95%}
|
||||||
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}
|
|
||||||
.settings-window>.dialog-head{
|
.settings-window>.dialog-head{
|
||||||
padding:18px}
|
padding:18px}
|
||||||
.settings-layout{
|
.settings-layout{
|
||||||
@@ -1164,3 +1141,12 @@ dialog p{
|
|||||||
gap:4px}
|
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}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user