diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93373894..7d40ac1c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@
### Fixed
+## [v0.50.244] — 2026-04-30
+
+### Added
+- **Text-to-Speech playback for agent responses** — Web Speech API powers a per-message 🔊 speaker button on every assistant message, plus an optional auto-read toggle that speaks each response when streaming finishes. Voice / rate / pitch controls are exposed in Settings → Preferences. All TTS preferences are stored in `localStorage` (no server round-trip). Strips markdown, code blocks, and `MEDIA:` paths before speaking; pauses synthesis when the composer is focused. Opt-in — TTS is hidden by default until enabled in Settings. Locale coverage for en, ru, es, de, zh, zh-Hant, pt, ko. (`static/ui.js`, `static/panels.js`, `static/messages.js`, `static/boot.js`, `static/style.css`, `static/index.html`, `static/i18n.js`) @fecolinhares — PR #1303, closes #499
+- **Sienna skin (warm clay & sand earth palette)** — opt-in alongside the existing default/Ares/Mono/Slate/Poseidon/Sisyphus/Charizard set. Full palette rewrite (light + dark variants) with clay accent (`#D97757`) on a soft sand background; neutral tool-card chrome, accent-tinted active session indicator. No forced migration, default skin stays `default` (gold); users opt in via Settings → Skin. (`static/style.css`, `static/boot.js`, `static/index.html`, `tests/test_sienna_skin.py`) — PR #1307 (salvaged from #1084)
+
+### Fixed
+- **Cmd/Ctrl+K new chat works while a conversation is busy** — drops the `!S.busy` guard so users can start a new conversation mid-stream. The in-flight stream keeps running on its own session; the user just gets a fresh blank one. (`static/boot.js`, `tests/test_mobile_layout.py`) — PR #1306 (salvaged from #1084)
+- **Stale saved session 404 cleanup + structured `api()` errors** — when a saved session ID returns 404, `loadSession()` now clears `localStorage.hermes-webui-session` and rethrows so boot can fall through to the empty state instead of sticking on "Session not available in web UI." across reloads. The cleanup is gated on `!currentSid` so click-into-404 doesn't wipe state. The global `api()` helper now attaches `.status` / `.statusText` / `.body` to thrown errors, so callers can branch on HTTP status without re-parsing the message string. (`static/sessions.js`, `static/workspace.js`, `tests/test_stale_empty_session_restore.py`, `tests/test_1038_pwa_auth_redirect.py`) — PR #1304 (salvaged from #1084)
+
## [v0.50.243] — 2026-04-30
### Fixed
diff --git a/static/boot.js b/static/boot.js
index e6a1d267..5ef00292 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -531,7 +531,12 @@ document.addEventListener('keydown',async e=>{
// If the current session has no messages, just focus the composer rather than
// creating another empty session that will clutter the sidebar list (#1171).
if(S.session&&(S.session.message_count||0)===0){$('msg').focus();return;}
- if(!S.busy){await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();}
+ // Cmd/Ctrl+K should always create a new conversation, even while the current
+ // one is still streaming. The old !S.busy guard meant users had to wait for
+ // a long generation to finish before they could start something new — exactly
+ // the moment they want to switch context. newSession() leaves the in-flight
+ // stream running on its own session; the user just gets a fresh blank one.
+ await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
}
if(e.key==='Escape'){
// Close onboarding overlay if open (skip/dismiss the wizard)
@@ -639,6 +644,7 @@ const _SKINS=[
{name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']},
{name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
+ {name:'Sienna', colors:['#D97757','#C06A49','#9A523A']},
];
const _VALID_THEMES=new Set((_THEMES||[]).map(t=>t.value));
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
@@ -845,6 +851,8 @@ function applyBotName(){
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
}
applyBotName();
+ // TTS: apply enabled state on boot so buttons show/hide correctly (#499)
+ if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true');
}catch(e){
window._sendKey='enter';
window._showTokenUsage=false;
@@ -865,6 +873,7 @@ function applyBotName(){
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
}
applyBotName();
+ if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true');
}
// Non-blocking update check (fire-and-forget, once per tab session)
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
diff --git a/static/i18n.js b/static/i18n.js
index 628ee285..9ad3f2ed 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -441,6 +441,17 @@ const LOCALES = {
// Settings detail
settings_label_sound: 'Notification sound',
settings_desc_sound: 'Play a sound when the assistant finishes a response.',
+ // TTS (#499)
+ tts_listen: 'Listen',
+ tts_not_supported: 'Speech synthesis not supported in this browser.',
+ settings_label_tts: 'Text-to-Speech for responses',
+ settings_desc_tts: "Show a speaker button on each assistant message to read it aloud using your browser's speech synthesis.",
+ settings_label_tts_auto_read: 'Auto-read responses aloud',
+ settings_desc_tts_auto_read: 'Automatically speak each new assistant response when it finishes. Pauses when you start typing.',
+ settings_label_tts_voice: 'Voice',
+ settings_desc_tts_voice: "Preferred voice. Populated from your browser's available voices.",
+ settings_label_tts_rate: 'Speech rate',
+ settings_label_tts_pitch: 'Speech pitch',
settings_label_notifications: 'Browser notifications',
settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.',
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
@@ -1511,6 +1522,17 @@ const LOCALES = {
html_error: 'Не удалось загрузить предпросмотр HTML',
html_open_full: 'Открыть на всю страницу',
html_sandbox_label: 'Предпросмотр HTML',
+ // TTS (#499)
+ tts_listen: 'Прослушать',
+ tts_not_supported: 'Синтез речи не поддерживается',
+ settings_label_tts: 'Синтез речи для ответов',
+ settings_desc_tts: 'Показать кнопку динамика на сообщениях ассистента',
+ settings_label_tts_auto_read: 'Авто-чтение ответов',
+ settings_desc_tts_auto_read: 'Автоматически озвучивать ответы ассистента',
+ settings_label_tts_voice: 'Голос',
+ settings_desc_tts_voice: 'Выберите голос для синтеза речи',
+ settings_label_tts_rate: 'Скорость речи',
+ settings_label_tts_pitch: 'Тон речи',
},
es: {
@@ -2231,6 +2253,17 @@ const LOCALES = {
html_error: 'Error al cargar la vista previa de HTML',
html_open_full: 'Abrir página completa',
html_sandbox_label: 'Vista previa de HTML',
+ // TTS (#499)
+ tts_listen: 'Escuchar',
+ tts_not_supported: 'Síntesis de voz no disponible',
+ settings_label_tts: 'Texto a voz para respuestas',
+ settings_desc_tts: 'Mostrar botón de altavoz en mensajes del asistente',
+ settings_label_tts_auto_read: 'Leer respuestas automáticamente',
+ settings_desc_tts_auto_read: 'Leer en voz alta las respuestas del asistente automáticamente',
+ settings_label_tts_voice: 'Voz',
+ settings_desc_tts_voice: 'Seleccionar voz para síntesis de voz',
+ settings_label_tts_rate: 'Velocidad de voz',
+ settings_label_tts_pitch: 'Tono de voz',
},
de: {
@@ -2955,7 +2988,18 @@ const LOCALES = {
html_error: 'HTML-Vorschau konnte nicht geladen werden',
html_open_full: 'Vollständige Seite öffnen',
html_sandbox_label: 'HTML-Vorschau',
-},
+ // TTS (#499)
+ tts_listen: 'Anhören',
+ tts_not_supported: 'Sprachsynthese nicht verfügbar',
+ settings_label_tts: 'Text-zu-Sprache für Antworten',
+ settings_desc_tts: 'Lautsprecher-Symbol auf Assistenten-Nachrichten anzeigen',
+ settings_label_tts_auto_read: 'Antworten automatisch vorlesen',
+ settings_desc_tts_auto_read: 'Assistenten-Antworten automatisch vorlesen',
+ settings_label_tts_voice: 'Stimme',
+ settings_desc_tts_voice: 'Stimme für Sprachsynthese auswählen',
+ settings_label_tts_rate: 'Sprechgeschwindigkeit',
+ settings_label_tts_pitch: 'Tonhöhe',
+ },
zh: {
_lang: 'zh',
@@ -3673,6 +3717,17 @@ const LOCALES = {
excalidraw_empty: '空图表',
excalidraw_render_error: '渲染图表失败',
excalidraw_simplified: '简化 SVG 预览 — 与 Excalidraw 画布不完全相同',
+ // TTS (#499)
+ tts_listen: '收听',
+ tts_not_supported: '语音合成不可用',
+ settings_label_tts: '回复语音合成',
+ settings_desc_tts: '在助手消息上显示扬声器按钮',
+ settings_label_tts_auto_read: '自动朗读回复',
+ settings_desc_tts_auto_read: '自动朗读助手回复',
+ settings_label_tts_voice: '语音',
+ settings_desc_tts_voice: '选择语音合成声音',
+ settings_label_tts_rate: '语速',
+ settings_label_tts_pitch: '音调',
},
// Traditional Chinese (zh-Hant)
@@ -4499,6 +4554,17 @@ const LOCALES = {
html_error: 'HTML 預覽載入失敗',
html_open_full: '開啟完整頁面',
html_sandbox_label: 'HTML 預覽',
+ // TTS (#499)
+ tts_listen: '收聽',
+ tts_not_supported: '語音合成無法使用',
+ settings_label_tts: '回覆語音合成',
+ settings_desc_tts: '在助手訊息上顯示喇叭按鈕',
+ settings_label_tts_auto_read: '自動朗讀回覆',
+ settings_desc_tts_auto_read: '自動朗讀助手回覆',
+ settings_label_tts_voice: '語音',
+ settings_desc_tts_voice: '選擇語音合成聲音',
+ settings_label_tts_rate: '語速',
+ settings_label_tts_pitch: '音調',
},
@@ -5138,7 +5204,18 @@ const LOCALES = {
approval_skip: 'Pular',
approval_skip_title: 'Pular este prompt de aprovação',
approval_skip_all: 'Pular todos',
- approval_skip_all_title: 'Pular todos prompts de aprovação nesta sessão'
+ approval_skip_all_title: 'Pular todos prompts de aprovação nesta sessão',
+ // TTS (#499)
+ tts_listen: 'Ouvir',
+ tts_not_supported: 'Síntese de voz não disponível',
+ settings_label_tts: 'Texto para voz nas respostas',
+ settings_desc_tts: 'Mostrar botão de alto-falante nas mensagens do assistente',
+ settings_label_tts_auto_read: 'Ler respostas automaticamente',
+ settings_desc_tts_auto_read: 'Ler automaticamente as respostas do assistente',
+ settings_label_tts_voice: 'Voz',
+ settings_desc_tts_voice: 'Selecionar voz para síntese de voz',
+ settings_label_tts_rate: 'Velocidade da fala',
+ settings_label_tts_pitch: 'Tom da fala',
},
ko: {
_lang: 'ko',
@@ -5911,6 +5988,17 @@ const LOCALES = {
excalidraw_empty: '빈 다이어그램',
excalidraw_render_error: '다이어그램 렌더링 실패',
excalidraw_simplified: '단순화된 SVG 미리보기 — Excalidraw 캔버스와 픽셀 동일하지 않음',
+ // TTS (#499)
+ tts_listen: '듣기',
+ tts_not_supported: '음성 합성을 사용할 수 없습니다',
+ settings_label_tts: '답변 음성 합성',
+ settings_desc_tts: '도움말 메시지에 스피커 버튼 표시',
+ settings_label_tts_auto_read: '답변 자동 읽기',
+ settings_desc_tts_auto_read: '도움말 답변을 자동으로 읽어줌',
+ settings_label_tts_voice: '음성',
+ settings_desc_tts_voice: '음성 합성 음성 선택',
+ settings_label_tts_rate: '말 속도',
+ settings_label_tts_pitch: '말 톤',
},
};
diff --git a/static/index.html b/static/index.html
index 50d5a59e..b4441ceb 100644
--- a/static/index.html
+++ b/static/index.html
@@ -15,7 +15,7 @@
-
+
@@ -683,6 +683,41 @@
Play a sound when the assistant finishes a response.
+
+
+
+ Text-to-Speech for responses
+
+
Show a speaker button on each assistant message to read it aloud using your browser's speech synthesis.
+
+
+
+
+ Auto-read responses aloud
+
+
Automatically speak each new assistant response when it finishes. Pauses when you start typing.
+
+
+
Voice
+
+ Default system voice
+
+
Preferred voice. Populated from your browser's available voices.
+
+
+
Speech rate
+
+
+ 1.0x
+
+
+
+
Speech pitch
+
+
+ 1.0
+
+
diff --git a/static/messages.js b/static/messages.js
index c106ff2a..8ea4fd35 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -34,6 +34,10 @@ function _markActiveSessionViewedOnReturn() {
document.addEventListener('visibilitychange', _markActiveSessionViewedOnReturn);
window.addEventListener('focus', _markActiveSessionViewedOnReturn);
+// TTS: pause speech synthesis when user focuses the composer (#499)
+const _msgEl=document.getElementById('msg');
+if(_msgEl) _msgEl.addEventListener('focus', ()=>{ if('speechSynthesis' in window && speechSynthesis.speaking) speechSynthesis.pause(); });
+if(_msgEl) _msgEl.addEventListener('blur', ()=>{ if('speechSynthesis' in window && speechSynthesis.paused) speechSynthesis.resume(); });
async function send(){
const text=$('msg').value.trim();
@@ -846,6 +850,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});}
if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length);
syncTopbar();renderMessages();loadDir('.');
+ // TTS auto-read: speak the last assistant response if enabled (#499)
+ if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
}
_queueDrainSid=activeSid;renderSessionList();setBusy(false);setStatus('');
setComposerStatus('');
diff --git a/static/panels.js b/static/panels.js
index e8599331..aee02104 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -2603,6 +2603,13 @@ function _markSettingsDirty(){
_settingsDirty = true;
}
+// Apply TTS enabled state: show/hide TTS buttons on all assistant messages
+function _applyTtsEnabled(enabled){
+ document.querySelectorAll('.msg-tts-btn').forEach(btn=>{
+ btn.style.display=enabled?'':'none';
+ });
+}
+
function _appearancePayloadFromUi(){
return {
theme: ($('settingsTheme')||{}).value || localStorage.getItem('hermes-theme') || 'dark',
@@ -2781,6 +2788,46 @@ async function loadSettingsPanel(){
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});}
const soundCb=$('settingsSoundEnabled');
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});}
+ // TTS settings (localStorage-only, no server round-trip needed)
+ const ttsEnabledCb=$('settingsTtsEnabled');
+ if(ttsEnabledCb){ttsEnabledCb.checked=localStorage.getItem('hermes-tts-enabled')==='true';ttsEnabledCb.onchange=function(){localStorage.setItem('hermes-tts-enabled',this.checked?'true':'false');_applyTtsEnabled(this.checked);};}
+ const ttsAutoReadCb=$('settingsTtsAutoRead');
+ if(ttsAutoReadCb){ttsAutoReadCb.checked=localStorage.getItem('hermes-tts-auto-read')==='true';ttsAutoReadCb.onchange=function(){localStorage.setItem('hermes-tts-auto-read',this.checked?'true':'false');};}
+ // Populate voice selector from speechSynthesis
+ const ttsVoiceSel=$('settingsTtsVoice');
+ if(ttsVoiceSel&&'speechSynthesis' in window){
+ const populateVoices=()=>{
+ const voices=speechSynthesis.getVoices();
+ const current=localStorage.getItem('hermes-tts-voice')||'';
+ ttsVoiceSel.innerHTML='Default system voice ';
+ voices.forEach(v=>{
+ const opt=document.createElement('option');
+ opt.value=v.name;opt.textContent=v.name+(v.lang?' ('+v.lang+')':'');
+ if(v.name===current) opt.selected=true;
+ ttsVoiceSel.appendChild(opt);
+ });
+ };
+ populateVoices();
+ speechSynthesis.addEventListener('voiceschanged',populateVoices,{once:true});
+ ttsVoiceSel.onchange=function(){localStorage.setItem('hermes-tts-voice',this.value);};
+ }
+ // TTS rate/pitch sliders
+ const ttsRateSlider=$('settingsTtsRate');
+ const ttsRateValue=$('settingsTtsRateValue');
+ if(ttsRateSlider){
+ const savedRate=localStorage.getItem('hermes-tts-rate');
+ ttsRateSlider.value=savedRate||'1';
+ if(ttsRateValue) ttsRateValue.textContent=parseFloat(ttsRateSlider.value).toFixed(1)+'x';
+ ttsRateSlider.oninput=function(){if(ttsRateValue)ttsRateValue.textContent=parseFloat(this.value).toFixed(1)+'x';localStorage.setItem('hermes-tts-rate',this.value);};
+ }
+ const ttsPitchSlider=$('settingsTtsPitch');
+ const ttsPitchValue=$('settingsTtsPitchValue');
+ if(ttsPitchSlider){
+ const savedPitch=localStorage.getItem('hermes-tts-pitch');
+ ttsPitchSlider.value=savedPitch||'1';
+ if(ttsPitchValue) ttsPitchValue.textContent=parseFloat(ttsPitchSlider.value).toFixed(1);
+ ttsPitchSlider.oninput=function(){if(ttsPitchValue)ttsPitchValue.textContent=parseFloat(this.value).toFixed(1);localStorage.setItem('hermes-tts-pitch',this.value);};
+ }
const notifCb=$('settingsNotificationsEnabled');
if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});}
// show_thinking has no settings panel checkbox — controlled via /reasoning show|hide
diff --git a/static/sessions.js b/static/sessions.js
index 1e0bf696..5884c541 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -338,6 +338,14 @@ async function loadSession(sid){
if(_msgInner){
if(e.status===404){
_msgInner.innerHTML='Session not available in web UI.
';
+ // If this 404 was for the saved active-session ID (not a click-into request),
+ // wipe the stale localStorage value and rethrow so boot can fall through to
+ // the empty-state instead of sticking to a broken "Session not available" view.
+ if(!currentSid&&localStorage.getItem('hermes-webui-session')===sid){
+ localStorage.removeItem('hermes-webui-session');
+ if (_loadingSessionId === sid) _loadingSessionId = null;
+ throw e;
+ }
} else {
_msgInner.innerHTML='Failed to load session. Try switching sessions or refreshing.
';
if(typeof showToast==='function') showToast('Failed to load session',3000,'error');
diff --git a/static/style.css b/static/style.css
index 685fc95a..450a47c3 100644
--- a/static/style.css
+++ b/static/style.css
@@ -98,6 +98,67 @@
/* ── Skin: Charizard (orange) ── */
:root[data-skin="charizard"]{--accent:#EA580C;--accent-hover:#C2410C;--accent-bg:rgba(234,88,12,0.08);--accent-bg-strong:rgba(234,88,12,0.15);--accent-text:#C2410C;}
:root.dark[data-skin="charizard"]{--accent:#FB923C;--accent-hover:#F97316;--accent-bg:rgba(251,146,60,0.08);--accent-bg-strong:rgba(251,146,60,0.15);--accent-text:#FB923C;}
+ /* ── Skin: Sienna (warm clay & sand earth palette) ──
+ Full palette rewrite (not just --accent): warm off-white surface,
+ soft sand sidebar, clay accent. Sets --user-bubble-* for the redesigned
+ user bubble block, mutes tool-card chrome to neutral, and bumps the
+ reading column to a serif body for long-form prose. Opt-in via the
+ Settings → Skin picker; default skin stays "default" (gold). */
+ :root[data-skin="sienna"]{
+ --bg:#FAF9F5;--sidebar:#F0EEE6;--surface:#FFFFFF;
+ --border:#E7E4DB;--border2:#D7D3C7;
+ --text:#1F1E1C;--muted:#6B6A63;--strong:#141311;--em:#3E3D39;
+ --accent:#D97757;--accent-hover:#BF6545;--accent-text:#A55237;
+ --accent-bg:rgba(217,119,87,0.09);--accent-bg-strong:rgba(217,119,87,0.18);
+ --code-bg:#F5F3EC;--code-inline-bg:rgba(20,19,17,0.06);--code-text:#8A3E1A;--pre-text:#1F1E1C;
+ --topbar-bg:rgba(250,249,245,0.96);--main-bg:rgba(250,249,245,0.55);
+ --input-bg:rgba(20,19,17,0.035);--hover-bg:rgba(20,19,17,0.05);
+ --focus-ring:rgba(217,119,87,0.3);--focus-glow:rgba(217,119,87,0.1);
+ --blue:#2E6F9E;--gold:#A55237;
+ --user-bubble-bg:#ECE9DF;--user-bubble-border:#DED9CC;
+ --user-bubble-text:#1F1E1C;--user-bubble-placeholder:#6B6A63;
+ --user-selection-bg:rgba(217,119,87,0.28);--user-selection-text:#1F1E1C;
+ }
+ :root.dark[data-skin="sienna"]{
+ --bg:#1F1E1C;--sidebar:#262522;--surface:#2C2B28;
+ --border:#3A3935;--border2:#4A4843;
+ --text:#EDEBE3;--muted:#A3A197;--strong:#F7F5ED;--em:#C9C6BC;
+ --accent:#E0896D;--accent-hover:#D97757;--accent-text:#E6A88A;
+ --accent-bg:rgba(224,137,109,0.12);--accent-bg-strong:rgba(224,137,109,0.22);
+ --code-bg:#2A2926;--code-inline-bg:rgba(255,255,255,0.07);--code-text:#F0B593;--pre-text:#EDEBE3;
+ --topbar-bg:rgba(38,37,34,0.96);--main-bg:rgba(31,30,28,0.55);
+ --input-bg:rgba(255,255,255,0.045);--hover-bg:rgba(255,255,255,0.07);
+ --focus-ring:rgba(224,137,109,0.35);--focus-glow:rgba(224,137,109,0.1);
+ --blue:#8BB8D6;--gold:#E6A88A;
+ --user-bubble-bg:#34322E;--user-bubble-border:#42403B;
+ --user-bubble-text:#EDEBE3;--user-bubble-placeholder:#A3A197;
+ --user-selection-bg:rgba(224,137,109,0.3);--user-selection-text:#F7F5ED;
+ }
+ /* Specificity boosted to beat the :root:not(.dark) .new-chat-btn rule that
+ sets color:var(--accent-text) — without this, the solid-accent background
+ would collide with the inherited text color, producing clay-on-clay text. */
+ :root[data-skin="sienna"]:not(.dark) .new-chat-btn,
+ :root.dark[data-skin="sienna"] .new-chat-btn{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;box-shadow:0 1px 2px rgba(20,19,17,0.08);}
+ :root[data-skin="sienna"]:not(.dark) .new-chat-btn:hover,
+ :root.dark[data-skin="sienna"] .new-chat-btn:hover{background:var(--accent-hover);border-color:var(--accent-hover);color:#fff;}
+ /* Tool / thinking cards: mute the blue-ish Hermes highlights to neutral */
+ :root[data-skin="sienna"] .tool-card{background:rgba(20,19,17,0.025);border-color:var(--border);border-radius:10px;}
+ :root[data-skin="sienna"] .tool-card:hover{border-color:var(--border2);}
+ :root[data-skin="sienna"] .tool-card-running{background:var(--accent-bg);border-color:var(--accent-bg-strong);}
+ :root[data-skin="sienna"] .tool-card-name{color:var(--muted);font-weight:500;}
+ :root[data-skin="sienna"] .tool-arg-key{color:var(--accent-text);}
+ :root[data-skin="sienna"] .tool-card-more{color:var(--accent-text);}
+ :root[data-skin="sienna"] .tool-card-running-dot{background:var(--accent);}
+ :root.dark[data-skin="sienna"] .tool-card{background:rgba(255,255,255,0.03);}
+ /* User bubble: soft sand pill, not a saturated accent fill */
+ :root[data-skin="sienna"] .msg-row[data-role="user"] .msg-body{border-radius:16px;padding:12px 16px;}
+ :root[data-skin="sienna"] .msg-row[data-role="user"] .msg-body code{color:var(--user-bubble-text);background:rgba(20,19,17,0.08);}
+ /* Send button pop and active session indicator */
+ :root[data-skin="sienna"] button.send-btn {box-shadow:0 1px 3px rgba(217,119,87,0.3);}
+ :root[data-skin="sienna"] button.send-btn:hover {box-shadow:0 2px 8px rgba(217,119,87,0.45);}
+ :root[data-skin="sienna"] .session-item.active{border-left:2px solid var(--accent);}
+ :root[data-skin="sienna"] .composer-box{border-radius:18px;}
+
/* #594: app-dialog light mode overrides — base styles use hardcoded dark gradients */
:root:not(.dark) .app-dialog{
background:linear-gradient(180deg,rgba(240,237,232,.99),rgba(228,224,216,.99));
@@ -1226,6 +1287,10 @@
.msg-row:hover .msg-actions{opacity:1;}
.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;}
.msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);}
+/* TTS speaker button: hidden by default, shown when TTS enabled */
+.msg-tts-btn{display:none;}
+.msg-tts-btn[data-speaking="1"]{color:var(--accent);animation:tts-pulse 1s ease-in-out infinite;}
+@keyframes tts-pulse{0%,100%{opacity:1}50%{opacity:.5}}
/* ── Edit message inline ── */
.msg-edit-area{width:100%;background:rgba(255,255,255,.05);border:1px solid var(--accent-bg);border-radius:8px;color:var(--text);padding:10px 12px;font-size:14px;font-family:inherit;line-height:1.6;resize:none;outline:none;min-height:60px;box-sizing:border-box;box-shadow:0 0 0 3px var(--accent-bg);margin-top:4px;}
diff --git a/static/ui.js b/static/ui.js
index c81e53d0..c2acc61d 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -2033,6 +2033,118 @@ function copyMsg(btn){
}).catch(()=>showToast(t('copy_failed')));
}
+// ── TTS: Text-to-Speech via Web Speech API (#499) ──
+// Strips markdown, code blocks, and MEDIA: paths for clean speech output.
+function _stripForTTS(text){
+ // Remove code blocks entirely (```)
+ text=text.replace(/```[\s\S]*?```/g,' ');
+ // Remove inline code
+ text=text.replace(/`[^`]+`/g,' ');
+ // Strip bold/italic
+ text=text.replace(/\*\*(.+?)\*\*/g,'$1');
+ text=text.replace(/\*(.+?)\*/g,'$1');
+ text=text.replace(/__(.+?)__/g,'$1');
+ text=text.replace(/_(.+?)_/g,'$1');
+ // Strip headings
+ text=text.replace(/^#{1,6}\s+/gm,'');
+ // Strip links, keep text
+ text=text.replace(/\[([^\]]+)\]\([^)]+\)/g,'$1');
+ // Replace MEDIA: paths with a simple label
+ text=text.replace(/MEDIA:[^\s]+/g,'a file');
+ // Strip HTML tags that may leak through markdown
+ text=text.replace(/<[^>]+>/g,' ');
+ // Collapse whitespace
+ text=text.replace(/\s+/g,' ').trim();
+ return text;
+}
+
+let _ttsSpeaking=false;
+let _ttsCurrentUtterance=null;
+
+function speakMessage(btn){
+ if(!('speechSynthesis' in window)){
+ showToast(t('tts_not_supported')||'Speech synthesis not supported in this browser.');
+ return;
+ }
+ // If already speaking this message, stop
+ if(btn&&btn.dataset.speaking==='1'){
+ stopTTS();
+ return;
+ }
+ // Stop any current speech
+ stopTTS();
+
+ const row=btn?btn.closest('[data-raw-text]'):null;
+ const text=row?row.dataset.rawText:'';
+ if(!text) return;
+
+ const clean=_stripForTTS(text);
+ if(!clean) return;
+
+ const utter=new SpeechSynthesisUtterance(clean);
+
+ // Apply saved voice preference
+ const savedVoice=localStorage.getItem('hermes-tts-voice');
+ const voices=speechSynthesis.getVoices();
+ if(savedVoice&&voices.length){
+ const match=voices.find(v=>v.name===savedVoice);
+ if(match) utter.voice=match;
+ }
+
+ // Apply saved rate/pitch
+ const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
+ if(!isNaN(savedRate)) utter.rate= Math.min(2,Math.max(0.5,savedRate));
+ const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
+ if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
+
+ _ttsCurrentUtterance=utter;
+ _ttsSpeaking=true;
+ if(btn) btn.dataset.speaking='1';
+
+ utter.onend=()=>{ _ttsSpeaking=false; _ttsCurrentUtterance=null; if(btn) btn.dataset.speaking='0'; };
+ utter.onerror=()=>{ _ttsSpeaking=false; _ttsCurrentUtterance=null; if(btn) btn.dataset.speaking='0'; };
+
+ speechSynthesis.speak(utter);
+}
+
+function stopTTS(){
+ if('speechSynthesis' in window){
+ speechSynthesis.cancel();
+ }
+ _ttsSpeaking=false;
+ _ttsCurrentUtterance=null;
+ // Reset all speaking buttons
+ document.querySelectorAll('[data-speaking="1"]').forEach(btn=>{ btn.dataset.speaking='0'; });
+}
+
+function autoReadLastAssistant(){
+ if(!('speechSynthesis' in window)) return;
+ const pref=localStorage.getItem('hermes-tts-auto-read');
+ if(pref!=='true') return;
+ // Find the last assistant message segment in the DOM
+ const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
+ if(!rows.length) return;
+ const last=rows[rows.length-1];
+ const text=last.dataset.rawText||'';
+ if(!text.trim()) return;
+ const clean=_stripForTTS(text);
+ if(!clean) return;
+
+ const utter=new SpeechSynthesisUtterance(clean);
+ const savedVoice=localStorage.getItem('hermes-tts-voice');
+ const voices=speechSynthesis.getVoices();
+ if(savedVoice&&voices.length){
+ const match=voices.find(v=>v.name===savedVoice);
+ if(match) utter.voice=match;
+ }
+ const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
+ if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate));
+ const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
+ if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
+
+ speechSynthesis.speak(utter);
+}
+
// ── Reconnect banner (B4/B5: reload resilience) ──
const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking
const INFLIGHT_STATE_KEY = 'hermes-webui-inflight-state'; // localStorage snapshots for mid-stream reload recovery
@@ -2864,6 +2976,7 @@ function renderMessages(){
const undoBtn = isLastAssistant ? `${li('undo',13)} ` : '';
const retryBtn = isLastAssistant ? `${li('rotate-ccw',13)} ` : '';
const copyBtn = `${li('copy',13)} `;
+ const ttsBtn = !isUser ? `${li('volume-2',13)} ` : '';
const tsVal=m._ts||m.timestamp;
// _formatInServerTz handles fractional-hour offsets (India +0530 etc.)
// correctly via offset arithmetic; bare toLocaleString is the browser-tz fallback.
@@ -2871,7 +2984,7 @@ function renderMessages(){
const tsTitle=tsVal?(_fmtSv?_fmtSv(new Date(tsVal*1000),{}):new Date(tsVal*1000).toLocaleString()):'';
const tsTime=_formatMessageFooterTimestamp(tsVal);
const timeHtml = tsTime ? `${tsTime} ` : '';
- const footHtml = ``;
+ const footHtml = ``;
if(_isContextCompactionMessage(m)){
if(compressionState || referenceNode){
diff --git a/static/workspace.js b/static/workspace.js
index 87c71220..611774fa 100644
--- a/static/workspace.js
+++ b/static/workspace.js
@@ -16,8 +16,15 @@ async function api(path,opts={}){
const text=await res.text();
// Parse JSON error body and surface the human-readable message,
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
- try{const j=JSON.parse(text);throw new Error(j.error||j.message||text);}
- catch(e){if(e instanceof SyntaxError)throw new Error(text);throw e;}
+ let message=text;
+ try{const j=JSON.parse(text);message=j.error||j.message||text;}catch(e){}
+ // Attach the raw HTTP context so callers can branch on status (404 stale-session
+ // cleanup, 401 redirect, 503 retry, etc.) without re-parsing the message string.
+ const err=new Error(message);
+ err.status=res.status;
+ err.statusText=res.statusText;
+ err.body=text;
+ throw err;
}
const ct=res.headers.get('content-type')||'';
return ct.includes('application/json')?res.json():res.text();
diff --git a/tests/test_1038_pwa_auth_redirect.py b/tests/test_1038_pwa_auth_redirect.py
index 7dbcf79f..3830967b 100644
--- a/tests/test_1038_pwa_auth_redirect.py
+++ b/tests/test_1038_pwa_auth_redirect.py
@@ -36,12 +36,18 @@ class TestPWAAuthRedirect:
"workspace.js api() must redirect to /login on 401"
def test_workspace_js_401_before_throw(self):
- """The 401 redirect must come before the generic error throw."""
+ """The 401 redirect must come before any error throw."""
src = _workspace_js()
idx_401 = src.find("res.status===401")
+ # api() may throw via `throw new Error(...)` or via the structured
+ # `const err=new Error(...); ... throw err;` pattern that attaches HTTP
+ # context for callers. Either is fine — what matters is the 401 redirect
+ # short-circuits before the generic throw.
idx_throw = src.find("throw new Error")
+ if idx_throw == -1:
+ idx_throw = src.find("throw err")
assert idx_401 != -1, "401 guard not found in workspace.js"
- assert idx_throw != -1, "throw not found in workspace.js"
+ assert idx_throw != -1, "no error throw found in workspace.js"
assert idx_401 < idx_throw, \
"401 redirect must appear before the generic throw in workspace.js"
diff --git a/tests/test_499_tts_playback.py b/tests/test_499_tts_playback.py
new file mode 100644
index 00000000..21630dc9
--- /dev/null
+++ b/tests/test_499_tts_playback.py
@@ -0,0 +1,195 @@
+"""
+Tests for #499: TTS playback of agent responses via Web Speech API.
+
+Verifies that TTS utility functions, speaker button rendering, and
+settings controls are present in the WebUI codebase.
+"""
+import os
+import re
+
+STATIC_DIR = os.path.join(os.path.dirname(__file__), '..', 'static')
+
+
+def _read(filename):
+ return open(os.path.join(STATIC_DIR, filename), encoding='utf-8').read()
+
+
+class TestTtsUtilityFunctions:
+ """TTS core functions exist in ui.js."""
+
+ def test_strip_for_tts_exists(self):
+ src = _read('ui.js')
+ assert 'function _stripForTTS(' in src, \
+ "_stripForTTS function not found in ui.js"
+
+ def test_speak_message_exists(self):
+ src = _read('ui.js')
+ assert 'function speakMessage(' in src, \
+ "speakMessage function not found in ui.js"
+
+ def test_stop_tts_exists(self):
+ src = _read('ui.js')
+ assert 'function stopTTS(' in src, \
+ "stopTTS function not found in ui.js"
+
+ def test_auto_read_exists(self):
+ src = _read('ui.js')
+ assert 'function autoReadLastAssistant(' in src, \
+ "autoReadLastAssistant function not found in ui.js"
+
+ def test_strip_code_blocks(self):
+ """_stripForTTS must remove ``` code blocks."""
+ src = _read('ui.js')
+ assert re.search(r'_stripForTTS.*```', src, re.DOTALL), \
+ "_stripForTTS must handle fenced code blocks"
+
+ def test_strip_media_paths(self):
+ """_stripForTTS must replace MEDIA: paths."""
+ src = _read('ui.js')
+ assert 'MEDIA:' in src and 'a file' in src, \
+ "_stripForTTS must replace MEDIA: paths"
+
+ def test_uses_speech_synthesis(self):
+ """speakMessage must use window.speechSynthesis."""
+ src = _read('ui.js')
+ assert 'SpeechSynthesisUtterance' in src, \
+ "speakMessage must create SpeechSynthesisUtterance"
+ assert 'speechSynthesis.speak' in src, \
+ "speakMessage must call speechSynthesis.speak"
+
+
+class TestTtsSpeakerButton:
+ """Speaker button is rendered on assistant messages."""
+
+ def test_tts_button_rendered(self):
+ """ttsBtn must be generated for non-user messages."""
+ src = _read('ui.js')
+ assert 'msg-tts-btn' in src, \
+ "TTS button class not found in ui.js"
+
+ def test_tts_button_not_on_user_messages(self):
+ """ttsBtn must only be added for non-user (assistant) messages."""
+ src = _read('ui.js')
+ # Find the ttsBtn definition — it should have !isUser guard
+ tts_line = [l for l in src.splitlines() if 'msg-tts-btn' in l][0]
+ assert '!isUser' in tts_line or 'isUser' in tts_line, \
+ "TTS button should have user-check guard"
+
+ def test_tts_button_in_footer(self):
+ """ttsBtn must be included in the msg-actions span."""
+ src = _read('ui.js')
+ # The footHtml line should include ttsBtn
+ foot_lines = [l for l in src.splitlines() if 'footHtml' in l and 'msg-actions' in l]
+ assert any('ttsBtn' in l for l in foot_lines), \
+ "ttsBtn not included in footHtml msg-actions"
+
+ def test_tts_button_uses_volume_icon(self):
+ """Speaker button should use volume-2 icon."""
+ src = _read('ui.js')
+ tts_line = [l for l in src.splitlines() if 'msg-tts-btn' in l][0]
+ assert 'volume-2' in tts_line, \
+ "TTS button should use volume-2 icon"
+
+
+class TestTtsSettings:
+ """TTS settings controls exist in the HTML and are wired in panels.js."""
+
+ def test_tts_enabled_checkbox(self):
+ src = _read('index.html')
+ assert 'settingsTtsEnabled' in src, \
+ "TTS enabled checkbox not found in index.html"
+
+ def test_tts_auto_read_checkbox(self):
+ src = _read('index.html')
+ assert 'settingsTtsAutoRead' in src, \
+ "TTS auto-read checkbox not found in index.html"
+
+ def test_tts_voice_selector(self):
+ src = _read('index.html')
+ assert 'settingsTtsVoice' in src, \
+ "TTS voice selector not found in index.html"
+
+ def test_tts_rate_slider(self):
+ src = _read('index.html')
+ assert 'settingsTtsRate' in src, \
+ "TTS rate slider not found in index.html"
+
+ def test_tts_pitch_slider(self):
+ src = _read('index.html')
+ assert 'settingsTtsPitch' in src, \
+ "TTS pitch slider not found in index.html"
+
+ def test_tts_settings_wired_in_panels(self):
+ """TTS settings must be initialized in loadSettingsPanel."""
+ src = _read('panels.js')
+ assert 'settingsTtsEnabled' in src, \
+ "TTS enabled setting not wired in panels.js"
+ assert '_applyTtsEnabled' in src, \
+ "_applyTtsEnabled not called in panels.js"
+
+ def test_apply_tts_enabled_function(self):
+ """_applyTtsEnabled must toggle msg-tts-btn display."""
+ src = _read('panels.js')
+ assert 'function _applyTtsEnabled(' in src, \
+ "_applyTtsEnabled function not found in panels.js"
+
+
+class TestTtsI18n:
+ """TTS i18n keys exist in the English locale."""
+
+ def test_tts_listen_key(self):
+ src = _read('i18n.js')
+ assert "tts_listen:" in src, \
+ "tts_listen key not found in i18n.js"
+
+ def test_tts_not_supported_key(self):
+ src = _read('i18n.js')
+ assert "tts_not_supported:" in src, \
+ "tts_not_supported key not found in i18n.js"
+
+ def test_tts_settings_keys(self):
+ src = _read('i18n.js')
+ for key in ['settings_label_tts', 'settings_label_tts_auto_read',
+ 'settings_label_tts_voice', 'settings_label_tts_rate',
+ 'settings_label_tts_pitch']:
+ assert f"{key}:" in src, f"{key} not found in i18n.js"
+
+
+class TestTtsAutoRead:
+ """Auto-read is triggered after SSE done event."""
+
+ def test_auto_read_called_in_messages(self):
+ src = _read('messages.js')
+ assert 'autoReadLastAssistant' in src, \
+ "autoReadLastAssistant not called in messages.js"
+
+ def test_tts_pause_on_composer_focus(self):
+ """Speech should pause when user focuses the composer."""
+ src = _read('messages.js')
+ assert 'speechSynthesis.pause' in src, \
+ "speechSynthesis.pause not called in messages.js"
+ assert 'speechSynthesis.resume' in src, \
+ "speechSynthesis.resume not called in messages.js"
+
+
+class TestTtsBoot:
+ """TTS enabled state is applied on page load."""
+
+ def test_apply_tts_on_boot(self):
+ src = _read('boot.js')
+ assert '_applyTtsEnabled' in src, \
+ "_applyTtsEnabled not called in boot.js"
+
+
+class TestTtsStyles:
+ """TTS CSS styles exist."""
+
+ def test_tts_button_hidden_default(self):
+ src = _read('style.css')
+ assert '.msg-tts-btn' in src, \
+ ".msg-tts-btn CSS class not found in style.css"
+
+ def test_tts_pulse_animation(self):
+ src = _read('style.css')
+ assert 'tts-pulse' in src, \
+ "tts-pulse animation not found in style.css"
diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py
index 6d687a7c..b46c6f14 100644
--- a/tests/test_mobile_layout.py
+++ b/tests/test_mobile_layout.py
@@ -157,11 +157,35 @@ def test_new_conversation_closes_mobile_sidebar():
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
- shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+6])
+ shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+12])
assert "closeMobileSidebar" in shortcut_block, \
"Cmd/Ctrl+K new chat shortcut must closeMobileSidebar() after creating the new session"
+def test_new_conversation_shortcut_works_while_busy():
+ """Cmd/Ctrl+K should still create a new conversation while the current one is busy.
+
+ The previous behavior gated the shortcut on !S.busy, which meant users had
+ to wait for a long generation to finish before they could start something
+ new — the exact moment they want to switch context.
+ """
+ boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
+ shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
+ assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
+ # Inspect the next 10 lines after the keybinding match — the gating block
+ # would live there if it had been kept.
+ idx = boot_js.splitlines().index(shortcut_line)
+ shortcut_block = "\n".join(boot_js.splitlines()[idx:idx + 10])
+ # Strip the existing message-count guard (which is unrelated and stays) so
+ # we only check for an S.busy gate on the newSession() call itself.
+ assert "if(!S.busy)" not in shortcut_block, (
+ "Cmd/Ctrl+K must not be blocked by the current session's busy state"
+ )
+ assert "if (!S.busy)" not in shortcut_block, (
+ "Cmd/Ctrl+K must not be blocked by the current session's busy state"
+ )
+
+
# ── Viewport and scroll safety ────────────────────────────────────────────────
def test_body_overflow_hidden():
diff --git a/tests/test_sienna_skin.py b/tests/test_sienna_skin.py
new file mode 100644
index 00000000..82baf7d5
--- /dev/null
+++ b/tests/test_sienna_skin.py
@@ -0,0 +1,79 @@
+"""Sienna skin: warm clay/sand earth palette, opt-in via Settings → Skin."""
+
+from pathlib import Path
+
+REPO = Path(__file__).parent.parent
+CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
+BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
+INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
+
+
+def test_sienna_skin_present_in_skins_list():
+ """The Sienna skin must be exposed in the picker grid via _SKINS."""
+ assert "{name:'Sienna'" in BOOT_JS, "Sienna skin missing from _SKINS list"
+ assert "'#D97757','#C06A49','#9A523A'" in BOOT_JS, (
+ "Sienna preview swatches missing"
+ )
+
+
+def test_sienna_skin_in_early_init_allowlist():
+ """The early-init skin allowlist must accept 'sienna'."""
+ assert "sienna:1" in INDEX_HTML, (
+ "Sienna missing from early-init skin allowlist; saved skin would be "
+ "rejected and reset to default on boot"
+ )
+
+
+def test_sienna_skin_palette_has_full_light_and_dark():
+ """Sienna defines both light and dark scoped palettes."""
+ assert ':root[data-skin="sienna"]{' in CSS, (
+ "Sienna light-mode palette block missing"
+ )
+ assert ':root.dark[data-skin="sienna"]{' in CSS, (
+ "Sienna dark-mode palette block missing"
+ )
+ # Spot-check that the palette is a full rewrite (not just --accent)
+ for token in ("--bg:#FAF9F5", "--sidebar:#F0EEE6", "--accent:#D97757"):
+ assert token in CSS, f"Sienna light palette token missing: {token}"
+ for token in ("--bg:#1F1E1C", "--sidebar:#262522", "--accent:#E0896D"):
+ assert token in CSS, f"Sienna dark palette token missing: {token}"
+
+
+def test_sienna_skin_does_not_force_migration():
+ """Sienna must not be silently migrated onto existing users.
+
+ The early-init script in index.html must NOT contain logic that flips an
+ existing 'default' skin to 'sienna' on first load. New users keep the gold
+ default; users opt in via Settings → Skin.
+ """
+ # The skin allowlist line should NOT contain a sienna-migration flag.
+ init_script_idx = INDEX_HTML.find("var themes=")
+ end_idx = INDEX_HTML.find("", init_script_idx)
+ init_block = INDEX_HTML[init_script_idx:end_idx]
+ forbidden = ["sienna-migrated", "skin-sienna-migrated", "skin='sienna'", 'skin="sienna"']
+ for marker in forbidden:
+ assert marker not in init_block, (
+ f"Sienna skin must be opt-in, not force-migrated. Found '{marker}' "
+ f"in early-init script."
+ )
+
+
+def test_default_theme_is_still_dark():
+ """Adding a new skin must not change the default theme."""
+ # The early-init script defaults to 'dark' when no saved theme exists.
+ init_script_idx = INDEX_HTML.find("var themes=")
+ end_idx = INDEX_HTML.find("", init_script_idx)
+ init_block = INDEX_HTML[init_script_idx:end_idx]
+ assert "||'dark'" in init_block, (
+ "Default theme must remain 'dark' (the existing baseline)"
+ )
+
+
+def test_sienna_new_chat_button_specificity_guards_against_clay_on_clay():
+ """The new-chat button needs higher specificity than the base
+ :root:not(.dark) .new-chat-btn rule, otherwise the inherited
+ color:var(--accent-text) collides with the solid-accent background and
+ produces invisible clay-on-clay text in light mode."""
+ assert ':root[data-skin="sienna"]:not(.dark) .new-chat-btn' in CSS, (
+ "Sienna light-mode .new-chat-btn override missing — clay-on-clay risk"
+ )
diff --git a/tests/test_stale_empty_session_restore.py b/tests/test_stale_empty_session_restore.py
new file mode 100644
index 00000000..11edf522
--- /dev/null
+++ b/tests/test_stale_empty_session_restore.py
@@ -0,0 +1,83 @@
+"""Regression tests for stale empty sessions after a WebUI restart.
+
+When a saved session ID returns 404 (e.g. the session was deleted from another
+browser, or a state DB rotation removed it), the prior behavior was to show
+\"Session not available in web UI.\" and stick there forever — the saved
+localStorage entry never got cleared, so every reload reproduced the broken
+state.
+
+These tests lock in:
+ 1. ``api()`` attaches HTTP context (``.status``, ``.statusText``, ``.body``)
+ to thrown errors so callers can branch on status without re-parsing text.
+ 2. ``loadSession()`` clears the stale ``hermes-webui-session`` key on a 404
+ for the currently-saved session and rethrows, so boot can fall through
+ to the empty state.
+"""
+
+from pathlib import Path
+import re
+
+
+REPO = Path(__file__).parent.parent
+WORKSPACE_JS = (REPO / "static" / "workspace.js").read_text(encoding="utf-8")
+SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
+
+
+def _api_body() -> str:
+ m = re.search(r"async function api\(path,opts=.*?\n\}", WORKSPACE_JS, re.DOTALL)
+ assert m, "api() function must exist in workspace.js"
+ return m.group(0)
+
+
+def _load_session_error_block() -> str:
+ start = SESSIONS_JS.find("data = await api(`/api/session?")
+ assert start > 0, "loadSession metadata request not found"
+ catch_idx = SESSIONS_JS.find("} catch(e) {", start)
+ assert catch_idx > start, "loadSession metadata catch block not found"
+ end = SESSIONS_JS.find("return;", catch_idx)
+ assert end > catch_idx, "loadSession metadata catch return not found"
+ return SESSIONS_JS[catch_idx:end]
+
+
+def test_api_http_errors_preserve_response_status():
+ """Callers must be able to distinguish stale-session 404s from generic failures."""
+ body = _api_body()
+ assert re.search(r"\w+\.status\s*=\s*res\.status", body), (
+ "api() must attach res.status to thrown HTTP errors"
+ )
+ assert re.search(r"\w+\.statusText\s*=\s*res\.statusText", body), (
+ "api() must attach res.statusText to thrown HTTP errors"
+ )
+ assert re.search(r"\w+\.body\s*=\s*text", body), (
+ "api() must attach the raw error body to thrown HTTP errors"
+ )
+
+
+def test_load_session_clears_saved_stale_404_and_rethrows_to_boot():
+ """A missing saved session should be removed and let boot show the empty state."""
+ block = _load_session_error_block()
+ assert "e.status===404" in block, "loadSession must keep a 404-specific branch"
+ assert "localStorage.getItem('hermes-webui-session')===sid" in block, (
+ "loadSession must only clear the saved active session key"
+ )
+ assert "localStorage.removeItem('hermes-webui-session')" in block, (
+ "loadSession must clear stale saved session IDs on 404"
+ )
+ assert "_loadingSessionId = null" in block, (
+ "loadSession must clear the in-flight load marker before rethrowing"
+ )
+ assert re.search(r"throw\s+e", block), (
+ "loadSession must rethrow the stale saved-session 404 so boot can fall "
+ "through to the no-session empty state"
+ )
+
+
+def test_click_into_404_does_not_clear_saved_session():
+ """A 404 from a click into a different session must NOT clear the saved active key."""
+ block = _load_session_error_block()
+ # The clearing branch is gated on `!currentSid` — i.e. only when the
+ # request was for the *saved* active session, not a user click on another.
+ assert "!currentSid" in block, (
+ "404 cleanup must be gated on !currentSid so user-initiated clicks "
+ "into a missing session don't wipe the saved active-session key"
+ )