diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6985e295..7d64f61c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,16 +3,22 @@
## [Unreleased]
-## [v0.51.154] — 2026-05-28 — Release DZ (stage-batch36 — 9-PR medium-risk cleanup: cron project chip + KaTeX streaming + recovery + .env keys + discoverability repair + media MEDIA tokens + gateway 401 + notes prefill + cron filter)
-
### Added
- Session discoverability audit now has a default-dry-run `--repair-safe` routine for deterministic cleanup: stale persisted WebUI-as-CLI flags can be cleared from sidecars/index entries, and messageful WebUI rows present only in `state.db` can be materialized into sidecars/index entries when `--apply --backup-dir
` is explicitly provided.
+
+- Browser chat can now opt into a default-off `HERMES_WEBUI_CHAT_BACKEND=gateway` bridge that routes new WebUI turns through a running Hermes Gateway API server while preserving the existing WebUI chat start/stream contract. Strict enable: only the literal values `gateway`, `api_server`, or `api-server` activate the bridge — generic truthy strings like `1` or `true` keep the legacy in-process WebUI runtime. Configurable via `HERMES_WEBUI_GATEWAY_BASE_URL` (default `http://127.0.0.1:8642`) and `HERMES_WEBUI_GATEWAY_API_KEY` (falls back to `API_SERVER_KEY`). New `api/gateway_chat.py` module isolates the bridge logic; existing direct WebUI chat path unchanged when the env/config is not set. (#3021)
+
### Changed
- The third-party notes drawer's "Recently used by AI" list now follows the provider-neutral WebUI-specific `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` / `webui_prefill_messages_script` hook when configured, including argv-style hooks such as `[python3, /path/to/recall.py]` and command strings such as `python3 /path/to/recall.py`, before falling back to the legacy generic `prefill_messages_script`. Configured third-party notes sources such as Joplin, Obsidian, Notion, and llm-wiki remain visible even before runtime tool inventory hydrates.
+
+- Local fallback title generation no longer has a German-only `Session Bilder` special case; it now uses the same generic topic extraction path as other fallback titles. (Refs #3040)
+- Title-generation prompts now use the same language-neutral "match the user language" instruction for every locale instead of adding German-only exemplars. (Refs #3040)
+- Session discoverability audit findings for stale persisted WebUI-as-CLI flags now report whether an API-visible lineage representative already covers the hidden snapshot, including the representative session id in JSON and Markdown output.
+
### Fixed
- Streaming KaTeX render passes now skip parser-owned equation placeholders that may still be receiving text, preventing long equations from being marked rendered before the final parser flush completes. (#2976)
@@ -25,13 +31,6 @@
## [v0.51.153] — 2026-05-28 — Release DY (stage-batch35 — 11-PR low-risk cleanup: title-language + clarify SSE + upload filename + discoverability + SSE reconnect + gateway image + docker docs)
-### Changed
-
-- Local fallback title generation no longer has a German-only `Session Bilder` special case; it now uses the same generic topic extraction path as other fallback titles. (Refs #3040)
-- Title-generation prompts now use the same language-neutral "match the user language" instruction for every locale instead of adding German-only exemplars. (Refs #3040)
-- Session discoverability audit findings for stale persisted WebUI-as-CLI flags now report whether an API-visible lineage representative already covers the hidden snapshot, including the representative session id in JSON and Markdown output.
-
-### Fixed
- Title-language detection no longer treats common English tech/jargon text such as "session die" or DAS/DER references as German just because of shared tokens. (Refs #3040)
- Clarify prompt SSE fallback polling now preserves its owner session id, matching approval polling behavior so terminal events from another session cannot stop the active clarify fallback poller.
@@ -41,16 +40,15 @@
- New chat sessions reset `_messagesTruncated` / `_oldestIdx` so a fresh conversation never displays the stale "Scroll up or click to load older messages" indicator inherited from a previously-paginated session.
- `openai-codex` reasoning-effort resolution now lets the existing `models.dev` metadata pass set the supported levels (including `xhigh`) instead of being silently clipped through the Copilot model heuristic.
+
+- Passive background refreshes such as sidebar/project polling, health checks, cron-status watches, and client-event logging no longer surface generic timeout toasts; explicit user actions still show timeout errors. (Related to #3024)
+
### Documentation
- Clarify two Docker onboarding traps: `sudo docker compose` can mount `/root/.hermes` instead of the user's Hermes home on Linux, and Linux Docker Engine users should use a `host-gateway` alias such as `api.local` for host-local model servers instead of configuring `localhost` inside the container. (#3006, #3012)
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
-### Added
-
-- Browser chat can now opt into a default-off `HERMES_WEBUI_CHAT_BACKEND=gateway` bridge that routes new WebUI turns through a running Hermes Gateway API server while preserving the existing WebUI chat start/stream contract. Strict enable: only the literal values `gateway`, `api_server`, or `api-server` activate the bridge — generic truthy strings like `1` or `true` keep the legacy in-process WebUI runtime. Configurable via `HERMES_WEBUI_GATEWAY_BASE_URL` (default `http://127.0.0.1:8642`) and `HERMES_WEBUI_GATEWAY_API_KEY` (falls back to `API_SERVER_KEY`). New `api/gateway_chat.py` module isolates the bridge logic; existing direct WebUI chat path unchanged when the env/config is not set. (#3021)
-
## [v0.51.151] — 2026-05-28 — Release DW (stage-batch33 — 3-PR mid-risk batch: SSE reattach + title-lang + composer cap)
### Fixed
diff --git a/static/panels.js b/static/panels.js
index 3261d900..fc8d4757 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -1145,7 +1145,7 @@ function _startCronWatch(jobId) {
_cronWatchStart = Date.now();
_cronWatchInterval = setInterval(async () => {
try {
- const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`);
+ const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false});
if (!data.running) {
_stopCronWatch();
if (_currentCronDetail && _currentCronDetail.id === jobId) {
@@ -1200,7 +1200,7 @@ function _formatElapsed(seconds) {
function _checkCronWatchOnDetail(jobId) {
// When opening a detail view, check if job is running
- api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`).then(data => {
+ api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false}).then(data => {
if (data.running && _currentCronDetail && _currentCronDetail.id === jobId) {
_startCronWatch(jobId);
}
diff --git a/static/sessions.js b/static/sessions.js
index 96a2f418..066d2246 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -2280,8 +2280,8 @@ async function renderSessionList(opts={}){
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
const allProfilesQS = _showAllProfiles ? '?all_profiles=1' : '';
const [sessData, projData] = await Promise.all([
- api('/api/sessions' + allProfilesQS),
- api('/api/projects' + allProfilesQS),
+ api('/api/sessions' + allProfilesQS,{timeoutToast:false}),
+ api('/api/projects' + allProfilesQS,{timeoutToast:false}),
]);
// Discard stale response — a newer renderSessionList() call superseded us.
if (_gen !== _renderSessionListGen) return;
@@ -2354,7 +2354,7 @@ async function refreshActiveSessionIfExternallyUpdated(reason){
const localLast = Number(S.session.last_message_at || S.session.updated_at || 0);
_activeSessionExternalRefreshInFlight = true;
try{
- const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
+ const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`,{timeoutToast:false});
if(!data || !data.session) return;
if(!S.session || S.session.session_id !== sid) return;
if(S.busy || S.activeStreamId) return;
diff --git a/static/ui.js b/static/ui.js
index b8bf5b1e..9bf025bd 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -475,7 +475,7 @@ async function refreshDashboardStatus(force=false){
return _dashboardStatusCache;
}
try{
- const status=await api('/api/dashboard/status');
+ const status=await api('/api/dashboard/status',{timeoutToast:false});
_dashboardStatusCache=status||{running:false};
}catch(_){
_dashboardStatusCache={running:false};
@@ -4588,7 +4588,7 @@ async function pollSystemHealth(){
if(document.visibilityState !== 'visible') return;
if(!_systemHealthPanelIsVisible()) return;
try{
- const payload=await api('/api/system/health');
+ const payload=await api('/api/system/health',{timeoutToast:false});
renderSystemHealth(payload);
}catch(_){
setSystemHealthUnavailable('Unavailable');
@@ -4654,7 +4654,7 @@ function dismissAgentHealthAlert(){
async function pollAgentHealth(){
if(document.visibilityState !== 'visible') return;
try{
- const payload=await api('/api/health/agent');
+ const payload=await api('/api/health/agent',{timeoutToast:false});
if(payload.alive === true){
_agentHealthLastState='alive';
_setAgentHealthDismissed(false);
diff --git a/static/workspace.js b/static/workspace.js
index 603bdc31..5f86dafa 100644
--- a/static/workspace.js
+++ b/static/workspace.js
@@ -3,6 +3,7 @@ async function api(path,opts={}){
const rel = path.startsWith('/') ? path.slice(1) : path;
const url=new URL(rel,document.baseURI||location.href);
const timeoutMs=Object.prototype.hasOwnProperty.call(opts,'timeoutMs')?opts.timeoutMs:30000;
+ const timeoutToast=opts.timeoutToast!==false;
// Retry up to 2 times on network errors (e.g. stale keep-alive after long idle).
// Server errors (4xx/5xx) and client-side timeouts are NOT retried.
let lastErr;
@@ -15,6 +16,8 @@ async function api(path,opts={}){
try{
const fetchOpts={...opts};
delete fetchOpts.timeoutMs;
+ delete fetchOpts.timeoutToast;
+
const useTimeout=Number.isFinite(Number(timeoutMs))&&Number(timeoutMs)>0;
if(useTimeout&&typeof AbortController!=='undefined'){
controller=new AbortController();
@@ -69,7 +72,7 @@ async function api(path,opts={}){
const err=(e&&e.name==='TimeoutError')?e:new Error('Request timed out. Please try again.');
err.name='TimeoutError';
err.timeout=true;
- if(typeof showToast==='function') showToast('Request timed out. Please try again.',5000,'error');
+ if(timeoutToast&&typeof showToast==='function') showToast('Request timed out. Please try again.',5000,'error');
throw err;
}
// Only retry on network errors (TypeError from fetch), not on HTTP errors
@@ -98,7 +101,7 @@ function recordClientSSEError(source, details={}){
url_path:(typeof location!=='undefined'&&location.pathname)||'/',
reason:details.reason||'EventSource.onerror',
};
- void api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000}).catch(()=>{});
+ void api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000,timeoutToast:false}).catch(()=>{});
}catch(_){}
}
diff --git a/tests/test_api_timeout.py b/tests/test_api_timeout.py
index 3f89f71a..f5a51fcc 100644
--- a/tests/test_api_timeout.py
+++ b/tests/test_api_timeout.py
@@ -159,13 +159,48 @@ def test_api_rejects_stalled_response_body_with_timeout():
assert any(event.get("aborted") for event in payload["events"]), payload
+def test_api_can_suppress_timeout_toast_for_background_pollers():
+ """Passive pollers need abort/reject cleanup without a user-visible toast."""
+ api_fn = _extract_js_function(_source(WORKSPACE_JS), "api")
+ script = textwrap.dedent(
+ f"""
+ const events=[];
+ global.document={{baseURI:'http://example.test/hermes/'}};
+ global.location={{href:'http://example.test/hermes/',pathname:'/hermes/',search:''}};
+ global.window={{location:global.location}};
+ global.showToast=(msg,ms,type)=>events.push({{msg:String(msg),ms,type}});
+ global.fetch=(url,opts)=>new Promise(()=>{{
+ if(opts&&opts.signal)opts.signal.addEventListener('abort',()=>events.push({{aborted:true}}));
+ }});
+ {api_fn}
+ api('/api/sessions',{{timeoutMs:20,timeoutToast:false}})
+ .then(()=>{{console.error('resolved unexpectedly');process.exit(2);}})
+ .catch(err=>{{
+ console.log(JSON.stringify({{message:String(err&&err.message||err),events}}));
+ process.exit(0);
+ }});
+ setTimeout(()=>{{console.error('api did not reject after timeoutMs');process.exit(3);}},250);
+ """
+ )
+ result = _node_eval(script, timeout=1.0)
+ assert result.returncode == 0, result.stderr or result.stdout
+ payload = json.loads(result.stdout.strip())
+ assert "timed out" in payload["message"].lower()
+ assert any(event.get("aborted") for event in payload["events"]), payload
+ assert not any("msg" in event for event in payload["events"]), payload
+
+
def test_api_has_default_timeout_and_per_call_override_contract():
src = _source(WORKSPACE_JS)
body = _extract_js_function(src, "api")
assert "timeoutMs" in body, "api() must accept opts.timeoutMs as a per-call override"
+ assert "timeoutToast" in body, "api() must let passive callers suppress timeout toasts"
+
assert "30000" in body, "api() must default browser API calls to a 30s timeout"
assert "AbortController" in body, "api() must abort hung fetches with AbortController"
assert "delete fetchOpts.timeoutMs" in body, "api() must strip timeoutMs before calling fetch()"
+ assert "delete fetchOpts.timeoutToast" in body, "api() must strip timeoutToast before calling fetch()"
+
fetch_call = re.search(r"fetch\(url\.href,\{.*?\.\.\.fetchOpts.*?\}\)", body, re.DOTALL)
assert fetch_call, "api() must call fetch() with sanitized fetchOpts"
assert "...opts" not in fetch_call.group(0), "api() must not spread raw opts into fetch()"
@@ -182,6 +217,23 @@ def test_update_flows_keep_explicit_longer_timeouts():
assert "api('/api/updates/force',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src
+def test_passive_background_polls_suppress_timeout_toasts():
+ """Passive refreshes should be best-effort and not emit generic timeout toasts."""
+ workspace = _source(WORKSPACE_JS)
+ sessions = _source(SESSIONS_JS)
+ ui = _source(UI_JS)
+ panels = _source(PANELS_JS)
+
+ assert "api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000,timeoutToast:false})" in workspace
+ assert "api('/api/sessions' + allProfilesQS,{timeoutToast:false})" in sessions
+ assert "api('/api/projects' + allProfilesQS,{timeoutToast:false})" in sessions
+ assert "api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`,{timeoutToast:false})" in sessions
+ assert "api('/api/dashboard/status',{timeoutToast:false})" in ui
+ assert "api('/api/system/health',{timeoutToast:false})" in ui
+ assert "api('/api/health/agent',{timeoutToast:false})" in ui
+ assert "api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false})" in panels
+
+
def test_new_session_inflight_cleanup_still_runs_after_api_rejects():
"""newSession() must keep its finally cleanup path so timeout rejections unpin the UI."""
src = _source(SESSIONS_JS)
diff --git a/tests/test_dashboard_link_ui.py b/tests/test_dashboard_link_ui.py
index 787b869d..8c55f4c1 100644
--- a/tests/test_dashboard_link_ui.py
+++ b/tests/test_dashboard_link_ui.py
@@ -25,7 +25,7 @@ def test_dashboard_rail_item_sits_between_insights_and_settings_spacer():
def test_dashboard_frontend_fetches_status_with_sixty_second_cache():
assert "DASHBOARD_STATUS_TTL_MS=60000" in UI_JS
assert "function refreshDashboardStatus" in UI_JS
- assert "api('/api/dashboard/status')" in UI_JS
+ assert "api('/api/dashboard/status',{timeoutToast:false})" in UI_JS
assert "setInterval(refreshDashboardStatus,DASHBOARD_STATUS_TTL_MS)" in UI_JS
assert 'fetch("/api/dashboard/status"' not in UI_JS
assert "fetch('/api/dashboard/status'" not in UI_JS
diff --git a/tests/test_issue1611_session_profile_filtering.py b/tests/test_issue1611_session_profile_filtering.py
index c3a6d8d5..6c277187 100644
--- a/tests/test_issue1611_session_profile_filtering.py
+++ b/tests/test_issue1611_session_profile_filtering.py
@@ -134,10 +134,10 @@ def test_static_sessions_js_uses_all_profiles_query_when_toggle_on():
assert "_showAllProfiles ? '?all_profiles=1' : ''" in src, (
"Expected fetch path to flip on the toggle state"
)
- assert "api('/api/sessions' + allProfilesQS)" in src, (
+ assert "api('/api/sessions' + allProfilesQS,{timeoutToast:false})" in src, (
"Expected /api/sessions fetch to use the variant query"
)
- assert "api('/api/projects' + allProfilesQS)" in src, (
+ assert "api('/api/projects' + allProfilesQS,{timeoutToast:false})" in src, (
"Expected /api/projects fetch to use the variant query"
)
diff --git a/tests/test_issue693_system_health_panel.py b/tests/test_issue693_system_health_panel.py
index 1161dc0a..81b58438 100644
--- a/tests/test_issue693_system_health_panel.py
+++ b/tests/test_issue693_system_health_panel.py
@@ -159,7 +159,7 @@ def test_system_health_panel_markup_and_styles_live_under_insights_not_top_chrom
def test_system_health_frontend_polls_visible_and_renders_progress_labels():
assert "const SYSTEM_HEALTH_INTERVAL_MS=5000" in UI_JS
- assert "api('/api/system/health')" in UI_JS
+ assert "api('/api/system/health',{timeoutToast:false})" in UI_JS
assert "document.visibilityState !== 'visible'" in UI_JS
assert "document.querySelector('main.main.showing-insights')" in UI_JS
assert "document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility)" in UI_JS
diff --git a/tests/test_issue716_agent_heartbeat.py b/tests/test_issue716_agent_heartbeat.py
index e7bbed13..25024c76 100644
--- a/tests/test_issue716_agent_heartbeat.py
+++ b/tests/test_issue716_agent_heartbeat.py
@@ -186,7 +186,7 @@ def test_agent_health_banner_markup_and_styles_exist():
def test_agent_health_frontend_polls_only_visible_and_distinguishes_states():
assert "const AGENT_HEALTH_INTERVAL_MS=30000" in UI_JS
- assert "api('/api/health/agent')" in UI_JS
+ assert "api('/api/health/agent',{timeoutToast:false})" in UI_JS
assert "document.visibilityState !== 'visible'" in UI_JS
assert "document.addEventListener('visibilitychange',_syncAgentHealthMonitorVisibility)" in UI_JS
assert "if(payload.alive === true)" in UI_JS