diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd2f21..6d2f299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Hermes Web UI -- Changelog +## [v0.50.68] — 2026-04-16 + +### Fixed +- **Light theme: add/rename folder dialogs now use correct light colors** — `.app-dialog`, `.app-dialog-input`, `.app-dialog-btn`, `.app-dialog-close`, and `.file-rename-input` had hardcoded dark-mode backgrounds with no light-theme overrides. Dialog backgrounds, borders, and inputs now adapt correctly to the light theme. (Closes #594) +- **Workspace panel no longer snaps open then immediately closed** — on page load, `boot.js` was restoring the panel open/closed state from `localStorage` before knowing whether the loaded session has a workspace. `syncWorkspacePanelState()` then snapped it closed, causing a visible jank. The restore is now deferred until after `loadSession()` and only applied when the session actually has a workspace. (Closes #576) +- **Model dropdown reflects CLI model changes without server restart** — `/api/models` was returning a startup-cached snapshot of `config.yaml`. The fix adds a mtime-based reload check: if `config.yaml` has changed on disk since last read, the cache is refreshed before building the model list. Page refresh now picks up CLI model changes immediately. (Closes #585) +- **Docker Compose: macOS users guided on UID/GID setup** — the `docker-compose.yml` comment for `WANTED_UID`/`WANTED_GID` now explicitly notes that macOS UIDs start at 501 (not 1000) and tells users to run `id -u`/`id -g`. Also clarifies that the default `${HOME}/.hermes` volume mount works on both macOS and Linux. (Closes #567) +- **Voice transcription already shows "Transcribing…" spinner** — issue #590 noted that no feedback was shown between pressing stop and text appearing. This was already implemented (`setComposerStatus('Transcribing…')` fires before the fetch in `_transcribeBlob`). Confirmed and documented; closing as already fixed. + ## [v0.50.67] — 2026-04-16 ### Added diff --git a/api/config.py b/api/config.py index 1051e17..c4668da 100644 --- a/api/config.py +++ b/api/config.py @@ -165,6 +165,7 @@ else: # ── Config file (reloadable -- supports profile switching) ────────────────── _cfg_cache = {} _cfg_lock = threading.Lock() +_cfg_mtime: float = 0.0 # last known mtime of config.yaml; 0 = never loaded def _get_config_path() -> Path: @@ -189,6 +190,7 @@ def get_config() -> dict: def reload_config() -> None: """Reload config.yaml from the active profile's directory.""" + global _cfg_mtime with _cfg_lock: _cfg_cache.clear() config_path = _get_config_path() @@ -199,6 +201,10 @@ def reload_config() -> None: loaded = _yaml.safe_load(config_path.read_text()) if isinstance(loaded, dict): _cfg_cache.update(loaded) + try: + _cfg_mtime = Path(config_path).stat().st_mtime + except OSError: + _cfg_mtime = 0.0 except Exception: logger.debug("Failed to load yaml config from %s", config_path) @@ -702,6 +708,15 @@ def get_available_models() -> dict: 'groups': [{'provider': str, 'models': [{'id': str, 'label': str}]}] } """ + # Reload config from disk if config.yaml has changed since last load. + # This ensures CLI model changes are picked up on page refresh without + # a server restart, while avoiding clearing in-memory mocks during tests. (#585) + try: + _current_mtime = Path(_get_config_path()).stat().st_mtime + except OSError: + _current_mtime = 0.0 + if _current_mtime != _cfg_mtime: + reload_config() active_provider = None default_model = DEFAULT_MODEL groups = [] diff --git a/docker-compose.yml b/docker-compose.yml index 196129d..69f1699 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,13 +8,20 @@ services: - "127.0.0.1:8787:8787" # - "8787:8787" volumes: - # Within the containe the tool expects to find the .hermes location at /home/hermeswebui/.hermes, so we mount it there; this allows you to manage agent profiles and other features that rely on the .hermes directory from your host machine, make sure to adapt the path if your HERMES_HOME is different - # Mount hermes home for agent features and profile management + # Mount your Hermes home directory into the container. + # The default (${HOME}/.hermes) works on both macOS (/Users//.hermes) + # and Linux (/home//.hermes) — no change needed for standard installs. + # Only set HERMES_HOME explicitly if your .hermes lives somewhere non-standard. + # macOS note: set UID and GID below to match your user ID (run `id -u` and `id -g`). - ${HERMES_HOME:-${HOME}/.hermes}:/home/hermeswebui/.hermes # Your workspace directory shown on first launch (adapt if yours is different, the container will use the mounted /workspace) - ${HERMES_HOME:-${HOME}}/workspace:/workspace environment: - # Modify the UID and GID to match your user; docker compose starts as root by default, but the container will drop privileges to the specified UID/GID + # Set to your host user ID: run `id -u` and `id -g` to find them. + # On macOS, UIDs start at 501 (not 1000), so set UID and GID in a .env file: + # echo "UID=$(id -u)" >> .env + # echo "GID=$(id -g)" >> .env + # Without this, the container may not be able to read your mounted files. - WANTED_UID=${UID:-1000} - WANTED_GID=${GID:-1000} # Required: bind address and port diff --git a/static/boot.js b/static/boot.js index 13b2557..a70b5e0 100644 --- a/static/boot.js +++ b/static/boot.js @@ -683,13 +683,18 @@ function applyBotName(){ await loadWorkspaceList(); await loadOnboardingWizard(); _initResizePanels(); - // Restore workspace panel open/closed state from last visit - if(localStorage.getItem('hermes-webui-workspace-panel')==='open'){ - _workspacePanelMode='browse'; - } + // Workspace panel restore happens AFTER loadSession so we know if + // the session has a workspace — prevents the snap-open-then-closed flash (#576). const saved=localStorage.getItem('hermes-webui-session'); if(saved){ - try{await loadSession(saved);syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;} + try{ + await loadSession(saved); + // Only restore the panel from localStorage when the session actually has a workspace. + // Without this guard, sessions without a workspace snap open then immediately closed. + if(S.session&&S.session.workspace&&localStorage.getItem('hermes-webui-workspace-panel')==='open'){ + _workspacePanelMode='browse'; + } + syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;} catch(e){localStorage.removeItem('hermes-webui-session');} } // no saved session - show empty state, wait for user to hit + diff --git a/static/index.html b/static/index.html index 7d4712d..1c5b1a3 100644 --- a/static/index.html +++ b/static/index.html @@ -555,7 +555,7 @@
System
- v0.50.67 + v0.50.68
diff --git a/static/style.css b/static/style.css index 69a9903..79ce759 100644 --- a/static/style.css +++ b/static/style.css @@ -25,6 +25,31 @@ --input-bg:rgba(0,0,0,.03);--hover-bg:rgba(0,0,0,.05); --strong:#1a1715;--em:#5a544a;--code-text:#8b4513;--code-inline-bg:rgba(0,0,0,.06);--pre-text:#2c2825; } + /* #594: app-dialog light theme overrides — base styles use hardcoded dark gradients */ + :root[data-theme="light"] .app-dialog{ + background:linear-gradient(180deg,rgba(240,237,232,.99),rgba(228,224,216,.99)); + border-color:rgba(0,0,0,.12); + box-shadow:0 12px 40px rgba(0,0,0,.15); + } + :root[data-theme="light"] .app-dialog-input{ + background:rgba(0,0,0,.04);border-color:rgba(0,0,0,.2); + } + :root[data-theme="light"] .app-dialog-input:focus{ + border-color:rgba(45,111,163,.5);box-shadow:0 0 0 3px rgba(45,111,163,.12); + } + :root[data-theme="light"] .app-dialog-close{ + background:rgba(0,0,0,.04); + } + :root[data-theme="light"] .app-dialog-close:hover{background:rgba(0,0,0,.09);} + :root[data-theme="light"] .app-dialog-btn{background:rgba(0,0,0,.04);} + :root[data-theme="light"] .app-dialog-btn:hover{background:rgba(0,0,0,.09);} + :root[data-theme="light"] .app-dialog-btn.confirm{ + border-color:rgba(45,111,163,.45);background:rgba(45,111,163,.12);color:var(--blue); + } + :root[data-theme="light"] .app-dialog-btn.confirm:hover{background:rgba(45,111,163,.2);} + :root[data-theme="light"] .file-rename-input{ + background:rgba(0,0,0,.04); + } :root[data-theme="light"] ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);} :root[data-theme="light"] ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3);} :root[data-theme="light"] ::selection{background:rgba(45,111,163,.2);} diff --git a/tests/test_bugbatch_apr2026.py b/tests/test_bugbatch_apr2026.py new file mode 100644 index 0000000..ac55142 --- /dev/null +++ b/tests/test_bugbatch_apr2026.py @@ -0,0 +1,141 @@ +""" +Bug batch fixes — April 2026. + +Covers: +- #594: .app-dialog and .file-rename-input have light theme overrides in style.css +- #576: workspace panel localStorage restore is gated on session.workspace presence (boot.js) +- #585: get_available_models() calls reload_config() before reading config cache +- #567: docker-compose.yml comment mentions macOS UID mismatch +- #590: _transcribeBlob already calls setComposerStatus('Transcribing…') — confirmed present +""" +import pathlib +import re + +REPO_ROOT = pathlib.Path(__file__).parent.parent +STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") +BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8") +COMPOSE = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + + +# ── #594: light theme dialog overrides ─────────────────────────────────────── + +def test_594_app_dialog_has_light_theme_override(): + """style.css must have a light theme rule targeting .app-dialog background.""" + assert ':root[data-theme="light"] .app-dialog{' in STYLE_CSS or \ + ":root[data-theme='light'] .app-dialog{" in STYLE_CSS, ( + "Missing light theme override for .app-dialog — dialogs appear dark on light theme" + ) + + +def test_594_app_dialog_input_has_light_theme_override(): + """style.css must have a light theme rule for .app-dialog-input.""" + assert ":root[data-theme=\"light\"] .app-dialog-input{" in STYLE_CSS, ( + "Missing light theme override for .app-dialog-input" + ) + + +def test_594_app_dialog_btn_has_light_theme_override(): + """style.css must have a light theme rule for .app-dialog-btn.""" + assert ":root[data-theme=\"light\"] .app-dialog-btn{" in STYLE_CSS, ( + "Missing light theme override for .app-dialog-btn" + ) + + +def test_594_app_dialog_close_has_light_theme_override(): + """style.css must have a light theme rule for .app-dialog-close.""" + assert ":root[data-theme=\"light\"] .app-dialog-close{" in STYLE_CSS, ( + "Missing light theme override for .app-dialog-close" + ) + + +def test_594_file_rename_input_has_light_theme_override(): + """style.css must have a light theme rule for .file-rename-input.""" + assert ":root[data-theme=\"light\"] .file-rename-input{" in STYLE_CSS, ( + "Missing light theme override for .file-rename-input" + ) + + +# ── #576: workspace panel snap fix ─────────────────────────────────────────── + +def test_576_panel_restore_gated_on_workspace(): + """boot.js: localStorage panel restore must be gated on session.workspace.""" + # The guard must appear: session.workspace check before _workspacePanelMode='browse' + assert "S.session&&S.session.workspace&&localStorage.getItem('hermes-webui-workspace-panel')" in BOOT_JS, ( + "Workspace panel localStorage restore must be gated on S.session.workspace " + "to prevent snap-open-then-closed on sessions without a workspace (#576)" + ) + + +def test_576_restore_happens_after_load_session(): + """boot.js: loadSession() must come before the panel restore guard.""" + load_pos = BOOT_JS.find("await loadSession(saved)") + restore_pos = BOOT_JS.find("S.session&&S.session.workspace&&localStorage") + assert load_pos != -1, "loadSession call not found in boot.js" + assert restore_pos != -1, "workspace panel restore guard not found" + assert load_pos < restore_pos, ( + "loadSession() must run before the panel restore guard " + "so S.session.workspace is known at restore time" + ) + + +# ── #585: get_available_models reloads config ───────────────────────────────── + +def test_585_get_available_models_calls_reload_config(): + """api/config.py: get_available_models() must do a mtime-based reload check.""" + config_src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8") + fn_start = config_src.find("def get_available_models()") + assert fn_start != -1, "get_available_models not found" + fn_body_end = config_src.find('"""', config_src.find('"""', fn_start + 30) + 3) + 3 + # Must check mtime before reading config + mtime_pos = config_src.find("_current_mtime", fn_body_end) + active_prov_pos = config_src.find("active_provider = None", fn_body_end) + assert mtime_pos != -1, ( + "get_available_models() must check config file mtime before reading cache (#585)" + ) + assert mtime_pos < active_prov_pos, ( + "mtime check must come before active_provider = None in get_available_models()" + ) + + +# ── #567: docker-compose UID note ───────────────────────────────────────────── + +def test_567_compose_mentions_macos_uid(): + """docker-compose.yml must mention macOS UID / id -u to help macOS users.""" + assert "macOS" in COMPOSE or "macos" in COMPOSE.lower(), ( + "docker-compose.yml should mention macOS UID issue (#567)" + ) + assert "id -u" in COMPOSE, ( + "docker-compose.yml should tell users to run 'id -u' to find their UID (#567)" + ) + + +# ── #590: transcription spinner already present ─────────────────────────────── + +def test_590_transcribing_status_shown_before_fetch(): + """boot.js: setComposerStatus('Transcribing…') must fire before the fetch call.""" + transcribe_fn_start = BOOT_JS.find("async function _transcribeBlob(") + assert transcribe_fn_start != -1, "_transcribeBlob not found in boot.js" + fn_body = BOOT_JS[transcribe_fn_start:transcribe_fn_start + 600] + status_pos = fn_body.find("setComposerStatus('Transcribing") + fetch_pos = fn_body.find("await fetch(") + assert status_pos != -1, ( + "setComposerStatus('Transcribing…') must be called before the fetch in _transcribeBlob" + ) + assert fetch_pos != -1, "await fetch not found in _transcribeBlob" + assert status_pos < fetch_pos, ( + "setComposerStatus('Transcribing…') must appear before 'await fetch' " + "so the UI shows a spinner immediately on stop (#590)" + ) + + +def test_590_recording_stops_before_transcribe(): + """boot.js: _setRecording(false) must fire in onstop before _transcribeBlob.""" + onstop_start = BOOT_JS.find("mediaRecorder.onstop") + assert onstop_start != -1, "mediaRecorder.onstop not found" + onstop_body = BOOT_JS[onstop_start:onstop_start + 400] + rec_pos = onstop_body.find("_setRecording(false)") + blob_pos = onstop_body.find("_transcribeBlob(") + assert rec_pos != -1 and blob_pos != -1 + assert rec_pos < blob_pos, ( + "_setRecording(false) must come before _transcribeBlob so mic icon clears immediately" + )