diff --git a/dockerfiles/hermes-webui-atlas-voice.css b/dockerfiles/hermes-webui-atlas-voice.css index 26f35072..0612733f 100644 --- a/dockerfiles/hermes-webui-atlas-voice.css +++ b/dockerfiles/hermes-webui-atlas-voice.css @@ -883,3 +883,52 @@ transform: none !important; } } + +/* New-session "Start conversation" affordance: a prominent, centred entry point + into conversation mode on the empty new-chat screen. Injected into #emptyState + by atlas-voice.js only when local voice is available, so it inherits the + empty state's own centred flex layout and show/hide. */ +.empty-conversation-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + margin: 2px auto 22px; + padding: 12px 22px; + min-height: 48px; + border-radius: 999px; + border: 1px solid var(--accent-bg-strong, rgba(24, 127, 139, 0.17)); + background: var(--accent-bg, rgba(24, 127, 139, 0.09)); + color: var(--accent-text, #126f7a); + font-family: inherit; + font-size: 14.5px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, + color 0.15s ease, transform 0.12s ease, box-shadow 0.15s ease; +} +.empty-conversation-btn:hover { + background: var(--accent, #187f8b); + border-color: var(--accent, #187f8b); + color: #fff; +} +.empty-conversation-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--focus-ring, rgba(24, 127, 139, 0.38)); +} +.empty-conversation-btn:active { + transform: translateY(1px); +} +.empty-conversation-icon { + display: inline-flex; + align-items: center; + line-height: 1; +} +@media (prefers-reduced-motion: reduce) { + .empty-conversation-btn { + transition: none; + } + .empty-conversation-btn:active { + transform: none; + } +} diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js index b164b256..b0b18292 100644 --- a/dockerfiles/hermes-webui-atlas-voice.js +++ b/dockerfiles/hermes-webui-atlas-voice.js @@ -3132,6 +3132,49 @@ if(active) deactivate(true); else activate(); } + const CONVERSATION_MIC_SVG=''; + + function isEmptyConversationButtonEnabled(){ + try{return localStorage.getItem('hermes-voice-mode-button')!=='false';}catch(_){return true;} + } + + function setupEmptyStateConversationButton(){ + // New-session affordance: a prominent, centred "Start conversation" button on + // the empty new-chat screen so conversation mode is discoverable without + // hunting for the small composer icon. Created only when voice is available; + // it lives INSIDE #emptyState, so it shows and hides with the new-session + // screen automatically, and it honours the same visibility preference as the + // composer toggle. Clicking it enters conversation mode through the same + // activate() path. Fully guarded so it degrades to nothing if the empty + // state is absent or the DOM shape changes. + try{ + const empty=document.getElementById('emptyState'); + if(!empty||document.getElementById('btnEmptyConversation')) return; + const btn=document.createElement('button'); + btn.type='button'; + btn.id='btnEmptyConversation'; + btn.className='empty-conversation-btn'; + btn.setAttribute('aria-label','Start conversation'); + const icon=document.createElement('span'); + icon.className='empty-conversation-icon'; + icon.setAttribute('aria-hidden','true'); + icon.innerHTML=CONVERSATION_MIC_SVG; + const text=document.createElement('span'); + text.className='empty-conversation-label'; + text.textContent='Start conversation'; + btn.appendChild(icon); + btn.appendChild(text); + btn.style.display=isEmptyConversationButtonEnabled()?'':'none'; + btn.addEventListener('click',function(){if(ready&&!active) activate();}); + // Place it under the subtitle, above the suggestion grid, so it sits in the + // vertical centre of the new-session screen. Fall back to the end if the + // grid is not found. + const grid=(typeof empty.querySelector==='function')?empty.querySelector('.suggestion-grid'):null; + if(grid&&grid.parentNode===empty&&typeof empty.insertBefore==='function') empty.insertBefore(btn,grid); + else empty.appendChild(btn); + }catch(error){conversationWarn('empty-state conversation button unavailable',error);} + } + async function initialize(){ try{ const response=await fetch('/api/transcribe/capability',{cache:'no-store'}); @@ -3165,11 +3208,14 @@ } modeBtn.style.display=localStorage.getItem('hermes-voice-mode-button')==='false'?'none':''; modeBtn.addEventListener('click',onVoiceClick,true); + setupEmptyStateConversationButton(); window._applyVoiceModePref=function(){ if(typeof originalApplyPreference==='function') originalApplyPreference(); if(ready){ const enabled=localStorage.getItem('hermes-voice-mode-button')!=='false'; modeBtn.style.display=enabled?'':'none'; + const emptyBtn=document.getElementById('btnEmptyConversation'); + if(emptyBtn) emptyBtn.style.display=enabled?'':'none'; if(!enabled&&active) deactivate(false); } }; diff --git a/testing/probes/hermes_voice_capture_probe.js b/testing/probes/hermes_voice_capture_probe.js index f137ea74..6cdb102f 100644 --- a/testing/probes/hermes_voice_capture_probe.js +++ b/testing/probes/hermes_voice_capture_probe.js @@ -390,7 +390,7 @@ function makeHarness(options = {}) { const sinkCalls = []; const elements = {}; - ['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg'] + ['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg', 'emptyState'] .forEach((id) => { elements[id] = makeElement(id); }); const bodyElement = makeElement('body'); @@ -835,6 +835,18 @@ function makeHarness(options = {}) { await flush(); await flush(); }, + // Boot the script (runs initialize(), which injects the empty-state + // conversation button when voice is available) WITHOUT entering voice mode. + async initVoice() { + await flush(); + await flush(); + }, + emptyConversationButton() { + const empty = elements.emptyState; + return (empty.children || []).find( + (child) => child && child.id === 'btnEmptyConversation', + ) || null; + }, }; } @@ -1053,6 +1065,31 @@ scenarios.errored_turn_is_not_spoken_and_capture_resyncs = async () => { }; }; +// The empty new-chat screen offers a prominent "Start conversation" button when +// voice is available; it enters conversation mode through the same activate() +// path as the small composer toggle. +scenarios.empty_state_conversation_button_enters_voice = async () => { + const harness = makeHarness(); + await harness.initVoice(); + const btn = harness.emptyConversationButton(); + const created = !!btn; + const visible = btn ? btn.style.display !== 'none' : false; + const ariaLabel = btn ? btn.getAttribute('aria-label') : ''; + const className = btn ? btn.className : ''; + const stateBefore = harness.state(); + if (btn) btn.click(); + await harness.flush(); + return { + created, + visible, + ariaLabel, + className, + stateBefore, + stateAfter: harness.state(), + overlayPresent: !!harness.overlay(), + }; +}; + // A TRANSIENT provider error (a broker 5xx/502 that blipped mid-conversation) // is auto-retried in place through the app's regenerate action instead of // dropping the user's utterance: the error is never spoken, the overlay stays diff --git a/testing/tests/test_hermes_voice_capture_continuity.py b/testing/tests/test_hermes_voice_capture_continuity.py index 32e70ebe..283bb66d 100644 --- a/testing/tests/test_hermes_voice_capture_continuity.py +++ b/testing/tests/test_hermes_voice_capture_continuity.py @@ -388,6 +388,39 @@ def test_errored_turn_is_never_spoken_and_capture_resyncs(probe_results): assert scenario["sends"] == ["alpha", "bravo"] +def test_empty_state_offers_centered_conversation_button(probe_results): + """The new-chat screen injects a prominent 'Start conversation' button that + enters conversation mode through the same activate() path as the composer + toggle — created only when voice is available, inside #emptyState.""" + scenario = probe_results["empty_state_conversation_button_enters_voice"] + assert scenario["created"] is True + assert scenario["visible"] is True + assert scenario["ariaLabel"] == "Start conversation" + assert scenario["className"] == "empty-conversation-btn" + # It is an entry point, not active until pressed; pressing it opens the + # conversation overlay and begins listening. + assert scenario["stateBefore"] == "" + assert scenario["stateAfter"] == "listening" + assert scenario["overlayPresent"] is True + + +def test_empty_state_conversation_button_source_and_style_contract(): + """The button is voice-capability gated, honours the composer toggle's + visibility preference, lives in #emptyState, and is styled + reduced-motion + aware.""" + source = VOICE_SCRIPT.read_text(encoding="utf-8") + css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(encoding="utf-8") + assert "function setupEmptyStateConversationButton()" in source + assert "setupEmptyStateConversationButton();" in source # wired in initialize() + assert "getElementById('emptyState')" in source + assert "btn.id='btnEmptyConversation'" in source + assert "if(ready&&!active) activate();" in source # same entry path, gated + # The composer toggle's hide preference also hides this button. + assert "emptyBtn.style.display=enabled?'':'none'" in source + assert ".empty-conversation-btn" in css + assert "prefers-reduced-motion: reduce" in css + + def test_transient_provider_error_auto_retries_then_speaks(probe_results): """A broker 5xx/502 blip is retried in place, not dropped: the error is never spoken, the overlay stays in Thinking, the app's regenerate action is