Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 15 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ def _discover_python(agent_dir: Path) -> str:
# ── 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:
Expand All @@ -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()
Expand All @@ -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)

Expand Down Expand Up @@ -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 = []
Expand Down
13 changes: 10 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down
2 changes: 1 addition & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@ <h3 style="margin:0;font-size:18px">Control Center</h3>
<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>
Expand Down
25 changes: 25 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);}
Expand Down
141 changes: 141 additions & 0 deletions tests/test_bugbatch_apr2026.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading