diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36121fa5..17517197 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,55 @@
# Hermes Web UI -- Changelog
+## [v0.50.292] — 2026-05-04
+
+### Fixed (12 PRs — multi-tab SSE + subpath routes + cross-source lineage + paste UX + 3 follow-ups)
+
+- **Multi-tab SSE no longer splits stream tokens between tabs** (#1598 by @Michaelyklam, closes #1584) — `api/config.py` introduces a `StreamChannel` broadcast class to replace the single-consumer `queue.Queue` previously stored in `STREAMS[stream_id]`. With the old design, the same session in two tabs was racing to consume tokens from one queue, so one tab might receive `H` while the other received `allo`. The new channel buffers events while no subscriber is connected (so the first tab sees the stream tail that arrived during the gap), and once one or more tabs are subscribed it broadcasts every event to all of them. `_handle_sse_stream()` calls `subscribe()` on connect and `unsubscribe()` in a `finally` block on disconnect/error. Per-stream wiring updated at all three producer callsites (`_handle_chat_start`, `_handle_btw`, `_handle_background`). Per Opus advisor on stage-292: replay-while-subscribing now happens inside the lock to prevent an event-ordering inversion when a 2nd tab subscribes mid-stream.
+
+- **Frontend routes now work under subpath mounts like `/hermes/`** (#1601 by @Michaelyklam) — auth redirect Location header (`api/auth.py`), 401-redirect helpers (`static/ui.js`, `static/workspace.js`), direct fetch/EventSource URLs (`static/{boot,messages,sessions}.js`), and the SMD vendor module import (`static/index.html`) all switched from root-absolute (`/login`, `/api/...`, `/static/...`) to mount-relative (`login`, `api/...`, `static/...`). Where appropriate, the mount-relative URL is anchored against `document.baseURI || location.href` so the `` element correctly resolves it under deep SPA routes. Per Opus advisor on stage-292: the gateway SSE probe in `static/sessions.js:1440` now also uses `document.baseURI || location.href` for parity with the other 5 callsites in this PR, ensuring it doesn't 404 under subpath at deep routes. Self-hosters running WebUI behind a reverse proxy or container ingress at a path prefix can now have everything work without Caddy/nginx rewrite workarounds.
+
+- **Streaming markdown now formats live segments under subpath mounts** (#1600 by @Michaelyklam) — `static/index.html` SMD module import switched to mount-relative form. `static/messages.js` fallback path (when `window.smd` isn't loaded) now passes the visible segment through `renderMd(fallbackText)` for the FIRST live segment as well as post-tool segments — previously the first segment was inserted as raw `parsed.displayText`, leaving markdown visible until the assistant's turn completed.
+
+- **Cross-source session continuations stay separate in the sidebar** (#1602 by @ai-ag2026) — `api/agent_sessions.py:_is_continuation_session()` now refuses to collapse parent/child where `parent.source != child.source`. A WebUI session continuing from a Telegram/CLI compression-chained parent stays visible as its own WebUI row instead of inheriting the old parent's title and source metadata. Non-continuation child rows now also expose `parent_title` + `parent_source` so the surface can show the lineage without losing the child's own identity.
+
+- **Paste no longer drops text when clipboard has both text and image** (#1622 by @s905060, closes #1620) — `static/boot.js` paste handler used to intercept on any `image/*` clipboard item, calling `preventDefault()` and attaching the image as a screenshot. Pasting from rich-text sources (Notes, Word, Slack, browser selections) attaches a rendered preview alongside the plain text — so the handler swallowed the text payload and only the rogue image was attached. Now defers to the browser's default text-paste when the clipboard also carries `text/plain` or `text/html` string items, and only intercepts when the clipboard is image-only (true screenshot paste). Image filter also tightened to `kind === 'file'` so string items advertising an image MIME (e.g. `text/html` with embedded data URIs) aren't misclassified.
+
+- **Forked session sidebar indicator is now recognizable and less noisy** (#1621 by @franksong2702, fixes #1613) — replaced the permanent `⑂` OCR glyph with the existing `git-branch` SVG icon, made the indicator subtle (.35 opacity) until row hover/focus/active states (.85 opacity), changed the tooltip to prefer the parent session title with a truncated-id fallback, and removed the hidden click-to-parent behavior from the sidebar row (was unpredictable). The `/branch` command and fork data model are unchanged.
+
+- **Update banner now shows tracked branches in labels** (#1605 by @ai-ag2026) — `static/ui.js` and `static/panels.js` use a new `_formatUpdateTargetStatus(label, info)` formatter that includes `info.branch` parenthetical, so `WebUI (origin/master): 0 updates, Agent (origin/main): 32 updates` is displayed in mixed states instead of the generic `Agent: 32 updates` that could be misread as the WebUI being behind. Settings panel uses a typeof-guarded fallback to a local formatter for back-compat with older boot states.
+
+- **Update compare URLs preserve git remote names ending in g/i/t** (#1603 by @ai-ag2026) — `api/updates.py` was using `str.rstrip('.git')` for the remote URL trim, which is a CHARACTER-CLASS strip — `'hermes-webui.git'` became `'hermes-webu'` (it strips trailing `g`, then `i`, then `.`, then more `i`'s, then `u`...). The updated logic checks `endswith('.git')` and slices the literal suffix, leaving `hermes-webui`/`hermes-agent` and any other remote name intact. Both HTTPS and SSH origin forms covered.
+
+- **`_pending_started_at` truthy-check fallback** (#1599 by @Sanjays2402, closes #1595) — `api/streaming.py:2058` tightens the per-turn duration fallback from `is not None` to a truthy check so `None`, missing-attr, and an explicit `0` all uniformly fall back to `time.time()`. Closes the loop on the v0.50.290 retro lesson — the v0.50.290 contributor's source-string assertion that pinned the old `is not None` form is removed by this PR. Behavioral assertions on the duration fallback remain.
+
+- **pytest config-path isolation** (#1597 by @Michaelyklam) — Hermes Agent sessions can set `HERMES_CONFIG_PATH` to the real `~/.hermes/config.yaml` before invoking pytest, so onboarding/provider tests could read/write the developer's live config. `tests/conftest.py` now overrides `HERMES_CONFIG_PATH` to point at the isolated test home before any product modules are imported. `api/providers.py:_clean_provider_key_from_config()` switches from import-time-bound `_get_config_path` to call-time resolution through `api.config._get_config_path()` so monkeypatches and tests work correctly.
+
+- **Cron worker no longer silently ignores profile-context failures** (#1608 by @franksong2702, closes #1578) — `_run_cron_tracked()` no longer wraps `cron_profile_context_for_home(profile_home).__enter__()` in a `try/except Exception` that silently sets `ctx = None`. A silent fallback in the worker thread leaves the job running unpinned against process-global `HERMES_HOME`, silently corrupting cross-profile state — same class of bug as #1573. Lets the exception propagate (kill the worker thread) rather than corrupt cross-profile state. Source-level regression test catches any future re-introduction of the over-broad except clause.
+
+- **TCP keepalive cleanup + macOS support** (#1609 by @franksong2702, closes #1583) — `server.py` cleanup follow-up to v0.50.289. Deletes the dead `QuietHTTPServer.server_bind()` override (TCP_KEEP* setsockopts on the listening socket are no-ops without SO_KEEPALIVE, which can't be set on a passive socket anyway). Splits `Handler.setup()` into proper ordering — TCP_NODELAY first, then SO_KEEPALIVE, then per-platform timing parameters: Linux uses `TCP_KEEPIDLE/INTVL/CNT`, macOS uses `TCP_KEEPALIVE`. Previously, on macOS, the entire try block aborted on the first `AttributeError` from `TCP_KEEPIDLE` and SO_KEEPALIVE was never applied — connections never had keepalive at all on Mac.
+
+### Tests
+
+4117 → **4142 passing** (+25 new regression tests across all 12 PRs). 0 regressions. Full suite in ~125s.
+
+### Pre-release verification
+
+- **Opus advisor**: SHIP verdict. Two SHOULD-FIX items absorbed in-release per <20-LOC defensive policy: (1) #1598 ordering race fixed by moving offline-buffer replay inside the subscribe lock; (2) #1601 sessions.js:1440 gateway SSE probe switched to `document.baseURI || location.href` for parity with PR's other 5 callsites.
+- **JS syntax**: all 6 modified .js files checked clean with `node -c`.
+- **Browser API sanity**: 11/11 endpoints OK on stage server.
+- **CHANGELOG / ROADMAP / TESTING**: stamps updated for v0.50.292 / 4142 baseline.
+
+### Authors
+
+- @Michaelyklam — 4 PRs (#1597, #1598, #1600, #1601)
+- @ai-ag2026 — 3 PRs (#1602, #1603, #1605)
+- @franksong2702 — 3 PRs (#1608, #1609, #1621)
+- @Sanjays2402 — 1 PR (#1599)
+- @s905060 — 1 PR (#1622)
+
+Closes #1578, #1583, #1584, #1595, #1613, #1620.
+
+
## [v0.50.291] — 2026-05-04
### Fixed (1 PR — "What's new?" link 404 — closes #1579)
diff --git a/ROADMAP.md b/ROADMAP.md
index 49db253a..5b925bf9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
>
-> Last updated: v0.50.291 (May 04, 2026) — 4117 tests collected
+> Last updated: v0.50.292 (May 04, 2026) — 4142 tests collected
> Test source: `pytest tests/ --collect-only -q`
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
diff --git a/TESTING.md b/TESTING.md
index a1726daf..9d4577cd 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
---
-*Last updated: v0.50.291, May 04, 2026*
-*Total automated tests collected: 4117*
+*Last updated: v0.50.292, May 04, 2026*
+*Total automated tests collected: 4142*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: /*
diff --git a/api/agent_sessions.py b/api/agent_sessions.py
index 15a30bf8..87061b73 100644
--- a/api/agent_sessions.py
+++ b/api/agent_sessions.py
@@ -79,9 +79,18 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
should continue the same visible conversation rather than becoming a
separate child-session row. Plain parent/child links that started before the
parent's ended boundary remain child sessions.
+
+ Do not collapse lineage across raw sources. A WebUI session that continues
+ from a Telegram/CLI/etc. parent must remain visible as its own surface-owned
+ conversation; otherwise the tip inherits the root's title/source metadata and
+ can disappear under messaging/sidebar policies.
"""
if not parent or not child:
return False
+ parent_source = str(parent.get('source') or '').strip().lower()
+ child_source = str(child.get('source') or '').strip().lower()
+ if parent_source and child_source and parent_source != child_source:
+ return False
if parent.get('end_reason') not in {'compression', 'cli_close'}:
return False
ended_at = parent.get('ended_at')
@@ -133,10 +142,13 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
if not parent_id:
continue
children_by_parent.setdefault(parent_id, []).append(row)
- if _is_continuation_session(rows_by_id.get(parent_id), row):
+ parent = rows_by_id.get(parent_id)
+ if _is_continuation_session(parent, row):
continuation_child_ids.add(row['id'])
else:
row['relationship_type'] = 'child_session'
+ row['parent_title'] = parent.get('title') if parent else None
+ row['parent_source'] = parent.get('source') if parent else None
parent_root = _continuation_root_id(rows_by_id, parent_id)
if parent_root:
row['_parent_lineage_root_id'] = parent_root
diff --git a/api/auth.py b/api/auth.py
index fa1e04cc..0927e5f7 100644
--- a/api/auth.py
+++ b/api/auth.py
@@ -256,7 +256,7 @@ def check_auth(handler, parsed) -> bool:
# safe='/' keeps path separators readable; everything else (including
# `?`, `&`, `=`) gets percent-encoded.
_next = _urlparse.quote(_path_with_query, safe='/')
- handler.send_header('Location', '/login?next=' + _next)
+ handler.send_header('Location', 'login?next=' + _next)
handler.end_headers()
return False
diff --git a/api/config.py b/api/config.py
index 5f0ffc06..3591fa9f 100644
--- a/api/config.py
+++ b/api/config.py
@@ -14,6 +14,7 @@ import copy
import json
import logging
import os
+import queue
import sys
import threading
import time
@@ -2745,6 +2746,57 @@ _INDEX_HTML_PATH = REPO_ROOT / "static" / "index.html"
LOCK = threading.Lock()
SESSIONS_MAX = 100
CHAT_LOCK = threading.Lock()
+
+
+class StreamChannel:
+ """Broadcast SSE events to every connected browser tab for a stream.
+
+ While no tab is connected, events are buffered so the first/reconnected
+ subscriber still receives the stream tail that arrived during the gap.
+ Once one or more subscribers are attached, new events are broadcast to all
+ of them instead of being consumed destructively by a single queue reader.
+ """
+
+ def __init__(self):
+ self._lock = threading.Lock()
+ self._subscribers: list[queue.Queue] = []
+ self._offline_buffer: list[tuple[str, object]] = []
+
+ def subscribe(self) -> queue.Queue:
+ q: queue.Queue = queue.Queue()
+ with self._lock:
+ # Replay buffered events to the new subscriber INSIDE the lock so a
+ # concurrent put_nowait() can't broadcast a newer event before we
+ # finish replaying the older buffered tail. queue.Queue.put_nowait
+ # is non-blocking on an unbounded queue, so holding the lock here
+ # is safe. Per Opus advisor on stage-292.
+ for item in self._offline_buffer:
+ q.put_nowait(item)
+ self._subscribers.append(q)
+ return q
+
+ def unsubscribe(self, q: queue.Queue) -> None:
+ with self._lock:
+ try:
+ self._subscribers.remove(q)
+ except ValueError:
+ pass
+
+ def put_nowait(self, item: tuple[str, object]) -> None:
+ with self._lock:
+ subscribers = list(self._subscribers)
+ if not subscribers:
+ self._offline_buffer.append(item)
+ return
+ self._offline_buffer.clear()
+ for q in subscribers:
+ q.put_nowait(item)
+
+
+def create_stream_channel() -> StreamChannel:
+ return StreamChannel()
+
+
STREAMS: dict = {}
STREAMS_LOCK = threading.Lock()
CANCEL_FLAGS: dict = {}
diff --git a/api/providers.py b/api/providers.py
index 86825774..546f675c 100644
--- a/api/providers.py
+++ b/api/providers.py
@@ -593,7 +593,13 @@ def _clean_provider_key_from_config(provider_id: str) -> None:
from api.config import _cfg_lock
try:
- config_path = _get_config_path()
+ # Resolve through api.config at call time instead of the function imported
+ # at module load. Several tests (and some profile flows) monkeypatch the
+ # config module's path resolver after api.providers has already been
+ # imported; using the stale imported reference can clean the wrong
+ # config.yaml.
+ import api.config as _config
+ config_path = _config._get_config_path()
except Exception:
return
diff --git a/api/routes.py b/api/routes.py
index 3c8b8086..7d968be8 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -198,14 +198,10 @@ def _run_cron_tracked(job, profile_home=None):
# (threads have no TLS, so get_active_hermes_home() can't resolve).
ctx = None
if profile_home is not None:
- try:
- from api.profiles import cron_profile_context_for_home
+ from api.profiles import cron_profile_context_for_home
- ctx = cron_profile_context_for_home(profile_home)
- ctx.__enter__()
- except Exception:
- logger.exception("Failed to pin profile %s for cron run", profile_home)
- ctx = None
+ ctx = cron_profile_context_for_home(profile_home)
+ ctx.__enter__()
try:
success, output, final_response, error = run_job(job)
@@ -332,6 +328,7 @@ from api.config import (
get_reasoning_status,
set_reasoning_display,
set_reasoning_effort,
+ create_stream_channel,
)
from api.helpers import (
require,
@@ -3649,9 +3646,10 @@ def _handle_list_dir(handler, parsed):
def _handle_sse_stream(handler, parsed):
stream_id = parse_qs(parsed.query).get("stream_id", [""])[0]
- q = STREAMS.get(stream_id)
- if q is None:
+ stream = STREAMS.get(stream_id)
+ if stream is None:
return j(handler, {"error": "stream not found"}, status=404)
+ subscriber = stream.subscribe() if hasattr(stream, "subscribe") else stream
handler.send_response(200)
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-cache")
@@ -3661,7 +3659,7 @@ def _handle_sse_stream(handler, parsed):
try:
while True:
try:
- event, data = q.get(timeout=30)
+ event, data = subscriber.get(timeout=30)
except queue.Empty:
handler.wfile.write(b": heartbeat\n\n")
handler.wfile.flush()
@@ -3671,6 +3669,12 @@ def _handle_sse_stream(handler, parsed):
break
except _CLIENT_DISCONNECT_ERRORS:
pass
+ finally:
+ if subscriber is not stream and hasattr(stream, "unsubscribe"):
+ try:
+ stream.unsubscribe(subscriber)
+ except Exception:
+ pass
return True
@@ -4812,9 +4816,9 @@ def _handle_btw(handler, body):
stream_id = uuid.uuid4().hex
ephemeral.active_stream_id = stream_id
ephemeral.save()
- q = queue.Queue()
+ stream = create_stream_channel()
with STREAMS_LOCK:
- STREAMS[stream_id] = q
+ STREAMS[stream_id] = stream
from api.background import track_btw
track_btw(body["session_id"], ephemeral.session_id, stream_id, question)
thr = threading.Thread(
@@ -4858,9 +4862,9 @@ def _handle_background(handler, body):
stream_id = uuid.uuid4().hex
bg.active_stream_id = stream_id
bg.save()
- q = queue.Queue()
+ stream = create_stream_channel()
with STREAMS_LOCK:
- STREAMS[stream_id] = q
+ STREAMS[stream_id] = stream
task_id = uuid.uuid4().hex[:8]
from api.background import track_background, complete_background
parent_sid = body["session_id"]
@@ -4974,9 +4978,9 @@ def _handle_chat_start(handler, body):
s.pending_started_at = time.time()
s.save()
set_last_workspace(workspace)
- q = queue.Queue()
+ stream = create_stream_channel()
with STREAMS_LOCK:
- STREAMS[stream_id] = q
+ STREAMS[stream_id] = stream
thr = threading.Thread(
target=_run_agent_streaming,
args=(s.session_id, msg, model, workspace, stream_id, attachments),
diff --git a/api/streaming.py b/api/streaming.py
index 9f079409..1ff71227 100644
--- a/api/streaming.py
+++ b/api/streaming.py
@@ -2054,8 +2054,10 @@ def _run_agent_streaming(
agent.ephemeral_system_prompt = _personality_prompt
_pending_started_at = getattr(s, 'pending_started_at', None)
# Normal chat-start sets pending_started_at before spawning this thread;
- # fallback to now only for recovered/legacy flows where that marker is absent.
- _turn_started_at = _pending_started_at if _pending_started_at is not None else time.time()
+ # fallback to now only for recovered/legacy flows where that marker is absent
+ # or has been zeroed out (e.g. via a buggy migration / manual file edit).
+ # Truthy-check covers None, missing-attr, and 0 uniformly.
+ _turn_started_at = _pending_started_at if _pending_started_at else time.time()
_previous_messages = list(s.messages or [])
_previous_context_messages = list(_session_context_messages(s))
_pre_compression_count = getattr(
diff --git a/api/updates.py b/api/updates.py
index ecc976fd..f4868851 100644
--- a/api/updates.py
+++ b/api/updates.py
@@ -207,9 +207,9 @@ def _check_repo(path, name):
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
# Convert SSH URLs (git@github.com:org/repo.git) to HTTPS
if remote_url and remote_url.startswith('git@'):
- remote_url = remote_url.replace(':', '/', 1).replace('git@', 'https://', 1).rstrip('.git')
- elif remote_url:
- remote_url = remote_url.rstrip('.git')
+ remote_url = remote_url.replace(':', '/', 1).replace('git@', 'https://', 1)
+ if remote_url and remote_url.endswith('.git'):
+ remote_url = remote_url[:-4]
return {
'name': name,
diff --git a/server.py b/server.py
index 7f3ad495..16691bb4 100644
--- a/server.py
+++ b/server.py
@@ -27,19 +27,6 @@ class QuietHTTPServer(ThreadingHTTPServer):
daemon_threads = True
request_queue_size = 64
- def server_bind(self):
- """Set socket options to prevent TIME_WAIT and CLOSE-WAIT accumulation."""
- # Enable address reuse to avoid "Address already in use" errors
- self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- # Enable TCP keepalive to detect dead connections (Linux)
- try:
- self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) # Start probing after 60s idle
- self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) # Probe every 10s
- self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) # Drop after 3 failed probes
- except (OSError, AttributeError):
- pass # TCP_KEEP* may not be available on all platforms
- super().server_bind()
-
def handle_error(self, request, client_address):
"""Override to suppress logging for common client disconnect errors."""
exc_type, exc_value, _ = sys.exc_info()
@@ -63,19 +50,31 @@ class Handler(BaseHTTPRequestHandler):
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
def setup(self):
- """Set additional socket options for each connection."""
+ """Set socket options for each accepted connection."""
super().setup()
- # Enable TCP keepalive on the connection socket (not just server socket)
+ # TCP_NODELAY — universal, disables Nagle for HTTP latency
+ try:
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+ except OSError:
+ pass
+ # SO_KEEPALIVE — universal master switch (must be set before timing params)
try:
- import socket
- self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # Disable Nagle's algorithm
- # Aggressive keepalive: start after 10s idle, probe every 5s, drop after 3 failures
- self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10)
- self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5)
- self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
self.connection.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
- except (OSError, AttributeError):
- pass # May not be available on all platforms
+ except OSError:
+ pass
+ # Per-platform timing parameters
+ if hasattr(socket, 'TCP_KEEPIDLE'): # Linux
+ try:
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10)
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5)
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
+ except OSError:
+ pass
+ elif hasattr(socket, 'TCP_KEEPALIVE'): # macOS
+ try:
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 10)
+ except OSError:
+ pass
_ver_suffix = WEBUI_VERSION.removeprefix('v')
server_version = ('HermesWebUI/' + _ver_suffix) if _ver_suffix != 'unknown' else 'HermesWebUI'
def log_message(self, fmt, *args): pass # suppress default Apache-style log
diff --git a/static/boot.js b/static/boot.js
index a54f75a5..598fcc57 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -965,8 +965,15 @@ document.addEventListener('keydown',async e=>{
});
$('msg').addEventListener('paste',e=>{
const items=Array.from(e.clipboardData?.items||[]);
- const imageItems=items.filter(i=>i.type.startsWith('image/'));
- if(!imageItems.length)return;
+ // When the clipboard carries BOTH text and an image (common from Notes,
+ // Word, browsers, Slack — the OS attaches a rendered preview alongside
+ // the plain text), prefer the text and let the browser paste normally.
+ // Only intercept when the clipboard is image-only (true screenshot paste).
+ // Tighten the image filter to kind==='file' so string items advertising an
+ // image MIME (e.g. text/html with an embedded data URI) are not misclassified.
+ const hasText=items.some(i=>i.kind==='string'&&(i.type==='text/plain'||i.type==='text/html'));
+ const imageItems=items.filter(i=>i.kind==='file'&&i.type.startsWith('image/'));
+ if(!imageItems.length||hasText)return;
e.preventDefault();
const files=imageItems.map(i=>{
const blob=i.getAsFile();
@@ -1287,7 +1294,7 @@ function applyBotName(){
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
- const _checkUrl='/api/updates/check'+(_testUpdates?'?simulate=1':'');
+ const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':'');
api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
}
// Fetch active profile
diff --git a/static/index.html b/static/index.html
index 2d22a9f1..025c95be 100644
--- a/static/index.html
+++ b/static/index.html
@@ -28,7 +28,7 @@