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
|
# 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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 +
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);}
|
||||||
|
|||||||
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