hermes(voice): add a centered Start conversation button to the new-chat screen

Conversation mode was only reachable via a small icon by the composer, which is
easy to miss on a fresh session. Inject a prominent, centred 'Start
conversation' button into the empty new-chat state (#emptyState), below the
subtitle and above the suggestions, so it sits in the vertical centre of the
screen. It is created only when local voice is available, honours the same
show/hide preference as the composer toggle, and enters conversation mode
through the same activate() path. Fully guarded so it degrades to nothing if the
empty state is absent. New probe scenario verifies it is created, visible, and
opens the conversation overlay on press.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
jenkins 2026-08-25 00:13:10 -03:00
parent a59590be79
commit a071d091b0
4 changed files with 166 additions and 1 deletions

View File

@ -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;
}
}

View File

@ -3132,6 +3132,49 @@
if(active) deactivate(true); else activate();
}
const CONVERSATION_MIC_SVG='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></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);
}
};

View File

@ -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

View File

@ -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