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:
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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/<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
|
||||
# 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
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -555,7 +555,7 @@
|
||||
<div class="settings-section-title">System</div>
|
||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||
</div>
|
||||
<span class="settings-version-badge">v0.50.67</span>
|
||||
<span class="settings-version-badge">v0.50.68</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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);}
|
||||
|
||||
141
tests/test_bugbatch_apr2026.py
Normal file
141
tests/test_bugbatch_apr2026.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user