fix: light theme dialogs, workspace panel snap, model cache staleness, docker-compose docs — v0.50.68

Fixes four bugs + locks in one existing fix with regression tests.

Closes #594 (light theme dialogs), #576 (workspace panel snap), #585 (stale model list after CLI change), #567 (docker-compose macOS UID docs). Confirms and tests #590 (transcribing spinner already present).

Reviewed and approved by @nesquena. 1340 tests passing.
This commit is contained in:
nesquena-hermes
2026-04-16 11:55:18 -07:00
committed by GitHub
parent 54e83fb8b6
commit 6c5911a79f
7 changed files with 211 additions and 9 deletions

View File

@@ -1,5 +1,14 @@
# Hermes Web UI -- Changelog # 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 ## [v0.50.67] — 2026-04-16
### Added ### Added

View File

@@ -165,6 +165,7 @@ else:
# ── Config file (reloadable -- supports profile switching) ────────────────── # ── Config file (reloadable -- supports profile switching) ──────────────────
_cfg_cache = {} _cfg_cache = {}
_cfg_lock = threading.Lock() _cfg_lock = threading.Lock()
_cfg_mtime: float = 0.0 # last known mtime of config.yaml; 0 = never loaded
def _get_config_path() -> Path: def _get_config_path() -> Path:
@@ -189,6 +190,7 @@ def get_config() -> dict:
def reload_config() -> None: def reload_config() -> None:
"""Reload config.yaml from the active profile's directory.""" """Reload config.yaml from the active profile's directory."""
global _cfg_mtime
with _cfg_lock: with _cfg_lock:
_cfg_cache.clear() _cfg_cache.clear()
config_path = _get_config_path() config_path = _get_config_path()
@@ -199,6 +201,10 @@ def reload_config() -> None:
loaded = _yaml.safe_load(config_path.read_text()) loaded = _yaml.safe_load(config_path.read_text())
if isinstance(loaded, dict): if isinstance(loaded, dict):
_cfg_cache.update(loaded) _cfg_cache.update(loaded)
try:
_cfg_mtime = Path(config_path).stat().st_mtime
except OSError:
_cfg_mtime = 0.0
except Exception: except Exception:
logger.debug("Failed to load yaml config from %s", config_path) 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}]}] '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 active_provider = None
default_model = DEFAULT_MODEL default_model = DEFAULT_MODEL
groups = [] groups = []

View File

@@ -8,13 +8,20 @@ services:
- "127.0.0.1:8787:8787" - "127.0.0.1:8787:8787"
# - "8787:8787" # - "8787:8787"
volumes: 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 your Hermes home directory into the container.
# Mount hermes home for agent features and profile management # The default (${HOME}/.hermes) works on both macOS (/Users/<you>/.hermes)
# and Linux (/home/<you>/.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 - ${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) # Your workspace directory shown on first launch (adapt if yours is different, the container will use the mounted /workspace)
- ${HERMES_HOME:-${HOME}}/workspace:/workspace - ${HERMES_HOME:-${HOME}}/workspace:/workspace
environment: 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_UID=${UID:-1000}
- WANTED_GID=${GID:-1000} - WANTED_GID=${GID:-1000}
# Required: bind address and port # Required: bind address and port

View File

@@ -683,13 +683,18 @@ function applyBotName(){
await loadWorkspaceList(); await loadWorkspaceList();
await loadOnboardingWizard(); await loadOnboardingWizard();
_initResizePanels(); _initResizePanels();
// Restore workspace panel open/closed state from last visit // Workspace panel restore happens AFTER loadSession so we know if
if(localStorage.getItem('hermes-webui-workspace-panel')==='open'){ // the session has a workspace — prevents the snap-open-then-closed flash (#576).
_workspacePanelMode='browse';
}
const saved=localStorage.getItem('hermes-webui-session'); const saved=localStorage.getItem('hermes-webui-session');
if(saved){ 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');} catch(e){localStorage.removeItem('hermes-webui-session');}
} }
// no saved session - show empty state, wait for user to hit + // no saved session - show empty state, wait for user to hit +

View File

@@ -555,7 +555,7 @@
<div class="settings-section-title">System</div> <div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div> <div class="settings-section-meta">Instance version and access controls.</div>
</div> </div>
<span class="settings-version-badge">v0.50.67</span> <span class="settings-version-badge">v0.50.68</span>
</div> </div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px"> <div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label> <label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -25,6 +25,31 @@
--input-bg:rgba(0,0,0,.03);--hover-bg:rgba(0,0,0,.05); --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; --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{background:rgba(0,0,0,.15);}
:root[data-theme="light"] ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3);} :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);} :root[data-theme="light"] ::selection{background:rgba(45,111,163,.2);}

View File

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