diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa46a4..0f68fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Hermes Web UI -- Changelog +## [v0.50.24] feat: opt-in chat bubble layout (closes #336) + +- `api/config.py`: Add `bubble_layout` bool to `_SETTINGS_DEFAULTS` (default `False`) and `_SETTINGS_BOOL_KEYS` — new setting is opt-in, server-persisted, and coerced to bool on save +- `static/style.css`: 11 lines of CSS-only bubble layout — user rows `align-self:flex-end` / max-width 75%, assistant rows `flex-start`, all gated on `body.bubble-layout` class so the default full-width canvas is untouched; 700px responsive rule widens to 92% +- `static/boot.js`: Apply `body.bubble-layout` class from settings on page load; explicitly remove the class in the catch path so the feature stays off on API failure +- `static/panels.js`: Load checkbox state in `loadSettingsPanel`; write `body.bubble_layout` in `saveSettings` and immediately toggle `body.bubble-layout` class for live preview without a page reload +- `static/index.html`: Checkbox in the Appearance settings group, positioned between Show token usage and Show agent sessions +- `static/i18n.js`: English label + description keys; Spanish translations included in the same PR +- `tests/test_issue336.py`: 22 new tests covering config registration, JS class management in boot and panels, CSS selectors, HTML structure, i18n coverage for en+es, and API round-trip (default false, persist true/false, bool coercion) +- 1003 tests total (up from 981) + ## [v0.50.23] Add OpenCode Zen and Go provider support (fixes #362) - `api/config.py`: Add `opencode-zen` and `opencode-go` to `_PROVIDER_DISPLAY` — providers now show human-readable names in the UI instead of raw IDs diff --git a/api/config.py b/api/config.py index 6f7ff9d..8607fb0 100644 --- a/api/config.py +++ b/api/config.py @@ -1117,6 +1117,7 @@ _SETTINGS_DEFAULTS = { ), # display name for the assistant "sound_enabled": False, # play notification sound when assistant finishes "notifications_enabled": False, # browser notification when tab is in background + "bubble_layout": False, # right-aligned user / left-aligned assistant chat bubbles "password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled } @@ -1146,6 +1147,7 @@ _SETTINGS_BOOL_KEYS = { "check_for_updates", "sound_enabled", "notifications_enabled", + "bubble_layout", } # Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr') _SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$") diff --git a/static/boot.js b/static/boot.js index 24450aa..3cd242d 100644 --- a/static/boot.js +++ b/static/boot.js @@ -479,7 +479,7 @@ function applyBotName(){ (async()=>{ // Load send key preference let _bootSettings={}; - try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);if(s.language&&typeof setLocale==='function'){setLocale(s.language);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};} + try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);document.body.classList.toggle('bubble-layout',!!s.bubble_layout);if(s.language&&typeof setLocale==='function'){setLocale(s.language);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};document.body.classList.remove('bubble-layout');} // Non-blocking update check (fire-and-forget, once per tab session) // ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards) const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1'; diff --git a/static/i18n.js b/static/i18n.js index a79a026..75e53f6 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -131,6 +131,7 @@ const LOCALES = { settings_label_theme: 'Theme', settings_label_language: 'Language', settings_label_token_usage: 'Show token usage', + settings_label_bubble_layout: 'Chat bubble layout', settings_label_cli_sessions: 'Show agent sessions', settings_label_sync_insights: 'Sync to insights', settings_label_check_updates: 'Check for updates', @@ -183,6 +184,7 @@ const LOCALES = { settings_label_notifications: 'Browser notifications', settings_desc_notifications: 'Show a system notification when a response completes while the tab is in the background.', settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.', + settings_desc_bubble_layout: 'Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.', settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.', settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.', settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.', @@ -396,6 +398,7 @@ const LOCALES = { settings_label_theme: 'Tema', settings_label_language: 'Idioma', settings_label_token_usage: 'Mostrar uso de tokens', + settings_label_bubble_layout: 'Disposición en burbujas', settings_label_cli_sessions: 'Mostrar sesiones de CLI', settings_label_sync_insights: 'Sincronizar con insights', settings_label_check_updates: 'Buscar actualizaciones', @@ -448,6 +451,7 @@ const LOCALES = { settings_label_notifications: 'Notificaciones del navegador', settings_desc_notifications: 'Muestra una notificación del sistema cuando una respuesta termina mientras la pestaña está en segundo plano.', settings_desc_token_usage: 'Muestra el conteo de tokens de entrada/salida debajo de cada respuesta del asistente. También se puede alternar con /usage.', + settings_desc_bubble_layout: 'Alinea los mensajes del usuario a la derecha y las respuestas del asistente a la izquierda. Desactivado por defecto para mantener los bloques de código y la salida de herramientas a ancho completo.', settings_desc_cli_sessions: 'Fusiona las sesiones del CLI de Hermes (state.db) en la lista de sesiones. Haz clic en una sesión de CLI para importarla y continuar la conversación.', settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.', settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.', diff --git a/static/index.html b/static/index.html index eb17c21..edc5903 100644 --- a/static/index.html +++ b/static/index.html @@ -494,6 +494,13 @@
Displays input/output token count below each assistant reply. Also toggled with /usage.
+
+ +
Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.
+
- v0.50.23 + v0.50.24
diff --git a/static/panels.js b/static/panels.js index 9fa07c9..8e556df 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1225,6 +1225,8 @@ async function loadSettingsPanel(){ if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});} const notifCb=$('settingsNotificationsEnabled'); if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});} + const bubbleCb=$('settingsBubbleLayout'); + if(bubbleCb){bubbleCb.checked=!!settings.bubble_layout;bubbleCb.addEventListener('change',_markSettingsDirty,{once:false});} // Bot name const botNameField=$('settingsBotName'); if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});} @@ -1267,6 +1269,8 @@ async function saveSettings(andClose){ body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked; body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked; body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked; + body.bubble_layout=!!($('settingsBubbleLayout')||{}).checked; + document.body.classList.toggle('bubble-layout', body.bubble_layout); const botName=(($('settingsBotName')||{}).value||'').trim(); body.bot_name=botName||'Hermes'; // Password: only act if the field has content; blank = leave auth unchanged diff --git a/static/style.css b/static/style.css index 8bf3445..939a498 100644 --- a/static/style.css +++ b/static/style.css @@ -350,6 +350,17 @@ @media(min-width:1800px){.messages-inner{max-width:1200px;}} .msg-row{padding:10px 0;} .msg-row+.msg-row{border-top:none;} + /* Bubble layout (issue #336): opt-in chat-bubble look with user messages right-aligned + and assistant messages left-aligned. Uses :has() to tag rows by role without JS + changes. Full-width by default -- enabled via body.bubble-layout from settings. */ + body.bubble-layout .msg-row:has(.msg-role.user){align-self:flex-end;max-width:75%;} + body.bubble-layout .msg-row:has(.msg-role.user) .msg-body{padding-left:0;padding-right:30px;max-width:none;} + body.bubble-layout .msg-row:has(.msg-role.user) .msg-role{flex-direction:row-reverse;} + body.bubble-layout .msg-row:has(.msg-role.assistant){align-self:flex-start;max-width:75%;} + @media(max-width:700px){ + body.bubble-layout .msg-row:has(.msg-role.user), + body.bubble-layout .msg-row:has(.msg-role.assistant){max-width:92%;} + } .msg-role{font-size:12px;font-weight:500;letter-spacing:.01em;margin-bottom:8px;display:flex;align-items:center;gap:8px;} .msg-role.user{color:rgba(124,185,255,0.65);} .msg-role.assistant{color:rgba(201,168,76,0.6);} diff --git a/tests/test_issue336.py b/tests/test_issue336.py new file mode 100644 index 0000000..25322ad --- /dev/null +++ b/tests/test_issue336.py @@ -0,0 +1,322 @@ +""" +Tests for issue #336 — opt-in chat bubble layout (PR #398). + +Covers: +- api/config.py: bubble_layout present in _SETTINGS_DEFAULTS with default False +- api/config.py: bubble_layout present in _SETTINGS_BOOL_KEYS +- api/config.py: bubble_layout not in password-filtered keys (safe to expose) +- static/boot.js: boot path applies bubble-layout class from settings +- static/boot.js: catch path removes bubble-layout class on API failure +- static/panels.js: loadSettingsPanel reads bubble_layout checkbox +- static/panels.js: saveSettings writes bubble_layout and toggles body class +- static/style.css: body.bubble-layout CSS selectors present +- static/style.css: responsive max-width rule for bubble layout +- static/index.html: settingsBubbleLayout checkbox element present +- static/index.html: i18n keys wired on label and description +- static/i18n.js: English label and description keys present +- static/i18n.js: Spanish label and description keys present +- Integration: bubble_layout default is False in GET /api/settings +- Integration: bubble_layout persists via POST /api/settings +- Integration: non-bool value is coerced to bool on POST +""" +import json +import pathlib +import re +import unittest +import urllib.error +import urllib.request + +REPO_ROOT = pathlib.Path(__file__).parent.parent +CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text() +BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text() +PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text() +STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text() +INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text() +I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text() + +BASE = "http://127.0.0.1:8788" + + +def _get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + + +def _post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request( + BASE + path, data=data, headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +# ── config.py static checks ─────────────────────────────────────────────── + + +class TestBubbleLayoutConfig(unittest.TestCase): + """Verify bubble_layout is correctly registered in config.py.""" + + def test_bubble_layout_in_settings_defaults(self): + """bubble_layout must appear in _SETTINGS_DEFAULTS.""" + self.assertIn( + '"bubble_layout"', + CONFIG_PY, + "bubble_layout key missing from _SETTINGS_DEFAULTS in api/config.py", + ) + + def test_bubble_layout_default_is_false(self): + """bubble_layout default value must be False (opt-in, off by default).""" + # Match "bubble_layout": False with optional spacing + self.assertRegex( + CONFIG_PY, + r'"bubble_layout"\s*:\s*False', + "bubble_layout default must be False in _SETTINGS_DEFAULTS", + ) + + def test_bubble_layout_in_bool_keys(self): + """bubble_layout must be in _SETTINGS_BOOL_KEYS for coercion.""" + # Find the _SETTINGS_BOOL_KEYS block and verify membership + bool_keys_match = re.search( + r"_SETTINGS_BOOL_KEYS\s*=\s*\{([^}]+)\}", CONFIG_PY, re.DOTALL + ) + self.assertIsNotNone( + bool_keys_match, "_SETTINGS_BOOL_KEYS block not found in config.py" + ) + self.assertIn( + '"bubble_layout"', + bool_keys_match.group(1), + "bubble_layout missing from _SETTINGS_BOOL_KEYS", + ) + + +# ── boot.js static checks ──────────────────────────────────────────────── + + +class TestBubbleLayoutBootJS(unittest.TestCase): + """Verify bubble-layout class management in boot.js.""" + + def test_boot_applies_bubble_layout_class(self): + """boot.js success path must toggle body.bubble-layout from settings.""" + self.assertIn( + "classList.toggle('bubble-layout',!!s.bubble_layout)", + BOOT_JS, + "boot.js must call classList.toggle('bubble-layout', ...) on settings load", + ) + + def test_boot_catch_removes_bubble_layout_class(self): + """boot.js catch path must remove bubble-layout (default off on API failure).""" + self.assertIn( + "classList.remove('bubble-layout')", + BOOT_JS, + "boot.js catch block must call classList.remove('bubble-layout') on API failure", + ) + + +# ── panels.js static checks ────────────────────────────────────────────── + + +class TestBubbleLayoutPanelsJS(unittest.TestCase): + """Verify settings panel wires the bubble_layout checkbox.""" + + def test_load_settings_reads_bubble_layout_checkbox(self): + """loadSettingsPanel must read the settingsBubbleLayout checkbox state.""" + self.assertIn( + "settingsBubbleLayout", + PANELS_JS, + "panels.js must reference settingsBubbleLayout checkbox", + ) + + def test_save_settings_writes_bubble_layout(self): + """saveSettings must write body.bubble_layout from the checkbox.""" + self.assertIn( + "body.bubble_layout", + PANELS_JS, + "saveSettings must set body.bubble_layout from checkbox", + ) + + def test_save_settings_toggles_body_class(self): + """saveSettings must apply body class toggle for live preview.""" + self.assertIn( + "classList.toggle('bubble-layout', body.bubble_layout)", + PANELS_JS, + "saveSettings must toggle 'bubble-layout' on document.body for live preview", + ) + + +# ── style.css static checks ────────────────────────────────────────────── + + +class TestBubbleLayoutCSS(unittest.TestCase): + """Verify CSS selectors for bubble layout are present and gated on body class.""" + + def test_user_row_right_align_selector_present(self): + """CSS must right-align user message rows when bubble-layout is active.""" + self.assertIn( + "body.bubble-layout .msg-row:has(.msg-role.user)", + STYLE_CSS, + "CSS selector for user bubble alignment missing from style.css", + ) + + def test_assistant_row_left_align_selector_present(self): + """CSS must left-align assistant message rows when bubble-layout is active.""" + self.assertIn( + "body.bubble-layout .msg-row:has(.msg-role.assistant)", + STYLE_CSS, + "CSS selector for assistant bubble alignment missing from style.css", + ) + + def test_bubble_layout_responsive_rule_present(self): + """A responsive max-width rule for narrow screens must be present.""" + # Both selectors must appear inside a @media block + self.assertRegex( + STYLE_CSS, + r"@media\([^)]*700px[^)]*\)[^{]*\{[^}]*bubble-layout", + "Responsive bubble-layout rule (700px breakpoint) missing from style.css", + ) + + +# ── index.html static checks ───────────────────────────────────────────── + + +class TestBubbleLayoutHTML(unittest.TestCase): + """Verify the settings checkbox is present and correctly wired in index.html.""" + + def test_settings_checkbox_present(self): + """The settingsBubbleLayout checkbox must exist in index.html.""" + self.assertIn( + 'id="settingsBubbleLayout"', + INDEX_HTML, + "settingsBubbleLayout checkbox missing from index.html", + ) + + def test_settings_label_i18n_key_wired(self): + """Label span must carry the settings_label_bubble_layout i18n key.""" + self.assertIn( + 'data-i18n="settings_label_bubble_layout"', + INDEX_HTML, + "settings_label_bubble_layout i18n key not wired on label span", + ) + + def test_settings_desc_i18n_key_wired(self): + """Description div must carry the settings_desc_bubble_layout i18n key.""" + self.assertIn( + 'data-i18n="settings_desc_bubble_layout"', + INDEX_HTML, + "settings_desc_bubble_layout i18n key not wired on description div", + ) + + +# ── i18n.js static checks ──────────────────────────────────────────────── + + +class TestBubbleLayoutI18N(unittest.TestCase): + """Verify English and Spanish locale keys are present in i18n.js.""" + + def _extract_locale_block(self, lang_start_marker, lang_end_marker): + """Extract the content between two locale markers.""" + start = I18N_JS.find(lang_start_marker) + end = I18N_JS.find(lang_end_marker, start) + self.assertGreater(start, -1, f"Start marker '{lang_start_marker}' not found") + self.assertGreater(end, start, f"End marker '{lang_end_marker}' not found after start") + return I18N_JS[start:end] + + def test_english_label_key_present(self): + """English locale must have settings_label_bubble_layout.""" + en_block = self._extract_locale_block("\n en: {", "\n es: {") + self.assertIn( + "settings_label_bubble_layout", + en_block, + "settings_label_bubble_layout missing from English locale", + ) + + def test_english_desc_key_present(self): + """English locale must have settings_desc_bubble_layout.""" + en_block = self._extract_locale_block("\n en: {", "\n es: {") + self.assertIn( + "settings_desc_bubble_layout", + en_block, + "settings_desc_bubble_layout missing from English locale", + ) + + def test_spanish_label_key_present(self): + """Spanish locale must have settings_label_bubble_layout.""" + es_block = self._extract_locale_block("\n es: {", "\n de: {") + self.assertIn( + "settings_label_bubble_layout", + es_block, + "settings_label_bubble_layout missing from Spanish locale", + ) + + def test_spanish_desc_key_present(self): + """Spanish locale must have settings_desc_bubble_layout.""" + es_block = self._extract_locale_block("\n es: {", "\n de: {") + self.assertIn( + "settings_desc_bubble_layout", + es_block, + "settings_desc_bubble_layout missing from Spanish locale", + ) + + +# ── Integration tests (require live server on port 8788) ───────────────── + + +class TestBubbleLayoutSettingsAPI(unittest.TestCase): + """Integration tests: bubble_layout via GET/POST /api/settings.""" + + def test_bubble_layout_default_is_false(self): + """GET /api/settings must return bubble_layout: false by default.""" + try: + d, status = _get("/api/settings") + except OSError: + self.skipTest("Server not running on port 8788") + self.assertEqual(status, 200) + self.assertIn( + "bubble_layout", + d, + "bubble_layout missing from GET /api/settings response", + ) + self.assertFalse( + d["bubble_layout"], + "bubble_layout default must be False (opt-in feature)", + ) + + def test_bubble_layout_persists_true(self): + """POST /api/settings with bubble_layout:true must persist and round-trip.""" + try: + _, status = _post("/api/settings", {"bubble_layout": True}) + except OSError: + self.skipTest("Server not running on port 8788") + self.assertEqual(status, 200) + d, _ = _get("/api/settings") + self.assertTrue(d["bubble_layout"], "bubble_layout=True must persist after POST") + # Restore + _post("/api/settings", {"bubble_layout": False}) + + def test_bubble_layout_persists_false(self): + """POST /api/settings with bubble_layout:false must persist and round-trip.""" + try: + _post("/api/settings", {"bubble_layout": True}) + _post("/api/settings", {"bubble_layout": False}) + except OSError: + self.skipTest("Server not running on port 8788") + d, _ = _get("/api/settings") + self.assertFalse(d["bubble_layout"], "bubble_layout=False must persist after POST") + + def test_bubble_layout_truthy_string_coerced_to_bool(self): + """Non-bool truthy value must be coerced to bool by _SETTINGS_BOOL_KEYS logic.""" + try: + _post("/api/settings", {"bubble_layout": "1"}) + except OSError: + self.skipTest("Server not running on port 8788") + d, _ = _get("/api/settings") + self.assertIsInstance( + d["bubble_layout"], + bool, + "bubble_layout must be a bool in API response (bool coercion via _SETTINGS_BOOL_KEYS)", + ) + # Restore + _post("/api/settings", {"bubble_layout": False})