From 2e876ea2295e4bf150626b46a9f208cbddae4734 Mon Sep 17 00:00:00 2001 From: tn801534 Date: Sun, 24 May 2026 20:37:33 +0800 Subject: [PATCH 01/11] fix: kanban worker log URL double query param on non-default boards --- static/panels.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/static/panels.js b/static/panels.js index 9726316244..6e94dff5d3 100644 --- a/static/panels.js +++ b/static/panels.js @@ -2388,8 +2388,7 @@ async function loadKanbanTask(taskId){ if (!taskId) return; try { const data = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + _kanbanBoardQuery()); - const logEndpoint = '/api/kanban/tasks/' + encodeURIComponent(taskId) + '/log' + _kanbanBoardQuery(); - try { data.log = await api(logEndpoint + '?tail=65536'); } catch(e) { data.log = {}; } + try { data.log = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + '/log' + _kanbanBoardQuery({tail: 65536})); } catch(e) { data.log = {}; } _kanbanCurrentTaskId = taskId; const task = data.task || {}; const title = _kanbanTaskTitle(task); From 618e1a5da8b8f3e40af37bd5563f8190838af306 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 24 May 2026 18:02:24 +0800 Subject: [PATCH 02/11] fix(server): tolerate malformed request logging --- CHANGELOG.md | 6 ++++++ server.py | 4 ++-- tests/test_issue2775_log_request.py | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 tests/test_issue2775_log_request.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b8eaaf3485..3a1b6db3b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +### Fixed + +- Malformed HTTP request logging now falls back to `"-"` for missing method or + path fields instead of raising an `AttributeError` traceback while handling + the 400 response. + ## [v0.51.124] — 2026-05-24 — Release CV (stage-batch6 — 3-PR Windows-only stack — agent paths / docs / port hardening) ### Added diff --git a/server.py b/server.py index 43f4a201a0..3888afc581 100644 --- a/server.py +++ b/server.py @@ -232,8 +232,8 @@ def log_request(self, code: str='-', size: str='-') -> None: duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1) record = _json.dumps({ 'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), - 'method': self.command or '-', - 'path': self.path or '-', + 'method': getattr(self, 'command', None) or '-', + 'path': getattr(self, 'path', None) or '-', 'status': int(code) if str(code).isdigit() else code, 'ms': duration_ms, }) diff --git a/tests/test_issue2775_log_request.py b/tests/test_issue2775_log_request.py new file mode 100644 index 0000000000..3beebb73f1 --- /dev/null +++ b/tests/test_issue2775_log_request.py @@ -0,0 +1,18 @@ +import json + +from server import Handler + + +def test_log_request_handles_malformed_request_without_path(capsys): + """Malformed request lines can call log_request before path is assigned.""" + handler = Handler.__new__(Handler) + handler.command = None + + Handler.log_request(handler, "400") + + line = capsys.readouterr().out.strip() + assert line.startswith("[webui] ") + record = json.loads(line.removeprefix("[webui] ")) + assert record["method"] == "-" + assert record["path"] == "-" + assert record["status"] == 400 From 32df5546b40b82a487f4e100eeb82b97c9e08b09 Mon Sep 17 00:00:00 2001 From: humayunak Date: Sun, 24 May 2026 07:53:13 +0500 Subject: [PATCH 03/11] fix(webui): prevent approval and clarify cards stealing focus from composer textarea When tool approval or clarification cards appear during streaming, they unconditionally call focus() on their input elements via setTimeout, stealing focus from the composer (#msg) if the user is actively typing. This silently drops keystrokes mid-type. Add a guard: only move focus to the card if the composer textarea does not already have focus. The document.activeElement check matches the pattern already used upstream in other focus-sensitive components. Fixes: # --- static/messages.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/static/messages.js b/static/messages.js index 170952ddc2..29c8ae40f0 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2428,7 +2428,9 @@ function showApprovalCard(pending, pendingCount) { card.classList.add("visible"); if (typeof applyLocaleToDOM === "function") applyLocaleToDOM(); const onceBtn = $("approvalBtnOnce"); - if (onceBtn) setTimeout(() => onceBtn.focus({preventScroll: true}), 50); + if (onceBtn && document.activeElement !== $('msg')) { + setTimeout(() => onceBtn.focus({preventScroll: true}), 50); + } } async function respondApproval(choice) { @@ -2864,7 +2866,11 @@ function showClarifyCard(pending) { card.classList.add("visible"); _syncClarifyCollapseButton(card); if (typeof applyLocaleToDOM === "function") applyLocaleToDOM(); - if (input && !sameClarify) setTimeout(() => input.focus({preventScroll: true}), 50); + // Move focus to clarify input synchronously (not in setTimeout) and + // only if the user wasn't mid-type in the composer textarea. + if (input && !sameClarify && document.activeElement !== $('msg')) { + input.focus({preventScroll: true}); + } } async function respondClarify(response) { From 9a5973a6b5f0978d61554e915cab0464cf6cfa90 Mon Sep 17 00:00:00 2001 From: tangerine-fan Date: Sun, 24 May 2026 14:38:14 +0800 Subject: [PATCH 04/11] feat: echo clarify user choice as visible message in conversation After the user responds to a clarify prompt, insert a synthetic user message into the conversation showing their choice. This makes the clarify interaction visible in the chat history, which was previously only shown in the transient clarify dialog card. The message is marked with _clarify_response: true so downstream consumers can distinguish it from regular user messages if needed. --- static/messages.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/static/messages.js b/static/messages.js index 29c8ae40f0..4bccc85ba1 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2902,6 +2902,16 @@ async function respondClarify(response) { _clarifyId = null; _clearClarifyPendingForSession(sid); hideClarifyCard(true, 'sent'); + // Echo the user's clarify choice as a visible message in the conversation + if (S.session && S.session.session_id === sid) { + S.messages.push({ + role: 'user', + content: value, + _clarify_response: true, + _ts: Date.now() / 1000, + }); + if (typeof renderMessages === 'function') renderMessages({preserveScroll: true}); + } } } else { // Stale / expired / wrong session — keep the card and draft visible. From 7a3ceacffe9af63c366d9c7a9b7b9b18d5d36d53 Mon Sep 17 00:00:00 2001 From: Koraji95-coder Date: Sun, 24 May 2026 15:52:26 +0000 Subject: [PATCH 05/11] fix(composer): stop chip wraps from compressing past their content (#2740) Squashed from 2 author commits: - a1017d02 initial fix: flex:0 0 auto on all 5 chip wraps - bf54ba50 Copilot review fix-up: consolidate into single rule Closes #2740. CSS-only, no JS changes. Default-width layout unchanged, only affects narrow-viewport overflow regime via composer-left's existing overflow-x:auto. --- static/style.css | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/static/style.css b/static/style.css index d010f4e11d..0f1354b96d 100644 --- a/static/style.css +++ b/static/style.css @@ -1377,7 +1377,16 @@ .composer-left{display:flex;align-items:center;gap:4px;min-width:0;flex:1;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;} .composer-left::-webkit-scrollbar{display:none;} .composer-divider{width:1px;height:16px;background:var(--border);margin:0 3px;flex-shrink:0;} - .composer-profile-wrap{position:relative;flex:0 1 auto;min-width:0;} + /* Composer footer chip wraps share position:relative + flex:0 0 auto so + they keep their natural width and let .composer-left handle overflow + via horizontal scroll. flex-shrink:0 here is what fixes #2740 (chips + were compressing past their content and visually overlapping). Each + wrap declares its own display / gap below as needed. */ + .composer-profile-wrap, + .composer-ws-wrap, + .composer-reasoning-wrap, + .composer-toolsets-wrap, + .composer-model-wrap{position:relative;flex:0 0 auto;} .composer-profile-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} .composer-profile-chip:hover{background-color:var(--hover-bg);} .composer-profile-chip.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);} @@ -1386,7 +1395,7 @@ .composer-profile-chip.switching .composer-profile-icon{position:relative;} .composer-profile-icon,.composer-profile-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;} .composer-profile-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} - .composer-ws-wrap{position:relative;flex:0 1 auto;min-width:0;display:flex;align-items:center;gap:4px;} + .composer-ws-wrap{display:flex;align-items:center;gap:4px;} .composer-workspace-group{display:inline-flex;align-items:stretch;max-width:284px;border-radius:999px;overflow:hidden;background-color:transparent;border:1px solid var(--border2);transition:background-color .15s,border-color .15s;} .composer-workspace-group:hover{background-color:var(--hover-bg);} .composer-workspace-group:hover{border-color:var(--border2);} @@ -1401,7 +1410,6 @@ .composer-workspace-chip.active{color:var(--text);background:var(--accent-bg);} .composer-workspace-icon,.composer-workspace-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;} .composer-workspace-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} - .composer-reasoning-wrap{position:relative;flex:0 1 auto;min-width:0;} .composer-reasoning-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} .composer-reasoning-chip.inactive{opacity:.78;} .composer-reasoning-chip:hover{color:var(--text);background-color:var(--hover-bg);} @@ -1414,7 +1422,7 @@ .reasoning-option:hover{background:rgba(255,255,255,.07);} .reasoning-option.selected{background:var(--accent-bg);} /* Toolsets chip — session-level toolset override (#493) */ - .composer-toolsets-wrap{position:relative;flex:0 1 auto;min-width:0;display:none;} + .composer-toolsets-wrap{display:none;} .composer-toolsets-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} .composer-toolsets-chip:hover{color:var(--text);background-color:var(--hover-bg);} .composer-toolsets-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);} @@ -1435,7 +1443,6 @@ .toolsets-apply-btn:hover{opacity:.9;} .toolsets-clear-btn{background:transparent;color:var(--muted);border:1px solid var(--border2);} .toolsets-clear-btn:hover{background:var(--hover-bg);color:var(--text);} - .composer-model-wrap{position:relative;flex:0 1 auto;min-width:0;} .composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:280px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} .composer-model-chip:hover{color:var(--text);background-color:var(--hover-bg);} .composer-model-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);} From b6f7412b53629c7307a0bb2cac0321fea2549f72 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Sun, 24 May 2026 10:20:31 -0400 Subject: [PATCH 06/11] Add option to ignore agent updates --- api/config.py | 2 ++ api/routes.py | 6 ++-- api/updates.py | 19 +++++++--- static/i18n.js | 22 ++++++++++++ static/index.html | 7 ++++ static/panels.js | 5 +++ tests/test_1003_preferences_autosave.py | 1 + tests/test_updates.py | 48 +++++++++++++++++++++++++ 8 files changed, 104 insertions(+), 6 deletions(-) diff --git a/api/config.py b/api/config.py index db4afe67dd..293dc95359 100644 --- a/api/config.py +++ b/api/config.py @@ -4365,6 +4365,7 @@ def _get_session_agent_lock(session_id: str) -> threading.Lock: "show_previous_messaging_sessions": False, # show older Telegram/Discord/etc. reset segments "sync_to_insights": False, # mirror WebUI token usage to state.db for /insights "check_for_updates": True, # check if webui/agent repos are behind upstream + "ignore_agent_updates": False, # keep WebUI update notices but suppress Agent update checks "whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links "theme": "dark", # light | dark | system "skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard | sienna | catppuccin | nous @@ -4524,6 +4525,7 @@ def load_settings() -> dict: "show_previous_messaging_sessions", "sync_to_insights", "check_for_updates", + "ignore_agent_updates", "whats_new_summary_enabled", "sound_enabled", "rtl", diff --git a/api/routes.py b/api/routes.py index eee4f166de..765a96f622 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4260,6 +4260,7 @@ def handle_get(handler, parsed) -> bool: settings = load_settings() if not settings.get("check_for_updates", True): return j(handler, {"disabled": True}) + include_agent_updates = not bool(settings.get("ignore_agent_updates")) qs = parse_qs(parsed.query) force = qs.get("force", ["0"])[0] == "1" # ?simulate=1 returns fake behind counts for UI testing (localhost only) @@ -4281,7 +4282,8 @@ def handle_get(handler, parsed) -> bool: }, "agent": { "name": "agent", - "behind": 1, + "behind": 1 if include_agent_updates else 0, + "ignored": not include_agent_updates, "current_sha": "aaa0001", "latest_sha": "bbb0002", "branch": "master", @@ -4293,7 +4295,7 @@ def handle_get(handler, parsed) -> bool: ) from api.updates import check_for_updates - return j(handler, check_for_updates(force=force)) + return j(handler, check_for_updates(force=force, include_agent=include_agent_updates)) if parsed.path == "/api/chat/stream/status": stream_id = parse_qs(parsed.query).get("stream_id", [""])[0] diff --git a/api/updates.py b/api/updates.py index a21563a6c5..2df4baf0f1 100644 --- a/api/updates.py +++ b/api/updates.py @@ -29,7 +29,7 @@ except ImportError: _AGENT_DIR = None -_update_cache = {'webui': None, 'agent': None, 'checked_at': 0} +_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True} _SUMMARY_CACHE_MAX = 16 _summary_cache: OrderedDict = OrderedDict() _cache_lock = threading.Lock() @@ -521,11 +521,21 @@ def _check_repo(path, name): return _check_repo_branch(path, name, fetch=False) -def check_for_updates(force=False): +def _ignored_agent_update_info() -> dict: + """Return a stable update-check payload for intentionally ignored Agent updates.""" + return {'name': 'agent', 'behind': 0, 'ignored': True} + + +def check_for_updates(force=False, *, include_agent=True): """Return cached update status for webui and agent repos.""" global _check_in_progress + include_agent = bool(include_agent) with _cache_lock: - if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL: + if ( + not force + and _update_cache.get('include_agent') == include_agent + and time.time() - _update_cache['checked_at'] < CACHE_TTL + ): return dict(_update_cache) if _check_in_progress: return dict(_update_cache) # another thread is already checking @@ -534,12 +544,13 @@ def check_for_updates(force=False): try: # Run checks outside the lock (network I/O) webui_info = _check_repo(REPO_ROOT, 'webui') - agent_info = _check_repo(_AGENT_DIR, 'agent') + agent_info = _check_repo(_AGENT_DIR, 'agent') if include_agent else _ignored_agent_update_info() with _cache_lock: _update_cache['webui'] = webui_info _update_cache['agent'] = agent_info _update_cache['checked_at'] = time.time() + _update_cache['include_agent'] = include_agent return dict(_update_cache) finally: _check_in_progress = False diff --git a/static/i18n.js b/static/i18n.js index 0f785a314b..0f1f88673d 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -567,6 +567,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Show previous messaging sessions', settings_label_sync_insights: 'Sync to insights', settings_label_check_updates: 'Check for updates', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Default assistant name', settings_label_password: 'Access Password', @@ -806,6 +807,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Show older Discord, Telegram, Slack, and Weixin sessions that were replaced by reset or compression.', settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.', settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Used for the default profile only. Other profiles use their own profile names.', settings_desc_password: 'Enter a new password to set or change it. Leave blank to keep current setting.', @@ -1803,6 +1805,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostra sessioni di messaggistica precedenti', settings_label_sync_insights: 'Sincronizza con insights', settings_label_check_updates: 'Verifica aggiornamenti', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nome assistente predefinito', settings_label_password: 'Password di Accesso', @@ -2034,6 +2037,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Mostra sessioni Discord, Telegram, Slack e Weixin più vecchie sostituite da reset o compressione.', settings_desc_sync_insights: 'Rispecchia l\'uso token WebUI su state.db così hermes /insights include i dati delle sessioni browser. Disattivato per impostazione predefinita.', settings_desc_check_updates: 'Mostra un banner quando sono disponibili versioni più recenti della WebUI o dell\'Agente. Esegue un git fetch in background periodicamente.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Usato solo per il profilo predefinito. Gli altri profili usano i propri nomi.', settings_desc_password: 'Inserisci una nuova password per impostarla o cambiarla. Lascia vuoto per mantenere l\'impostazione attuale.', @@ -3031,6 +3035,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '以前のメッセージングセッションを表示', settings_label_sync_insights: 'インサイトに同期', settings_label_check_updates: 'アップデートを確認', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'デフォルトのアシスタント名', settings_label_password: 'アクセスパスワード', @@ -3267,6 +3272,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'reset または compression によって置き換えられた以前の Discord、Telegram、Slack、Weixin セッションを表示します。', settings_desc_sync_insights: 'WebUI のトークン使用量を state.db にミラーし、hermes /insights にブラウザセッションのデータを含めます。デフォルトはオフ。', settings_desc_check_updates: 'WebUI または Agent の新しいバージョンが利用可能な時にバナーを表示します。バックグラウンドで定期的に git fetch を実行します。', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'デフォルトプロファイルでのみ使用されます。他のプロファイルはそれぞれのプロファイル名を使用します。', settings_desc_password: '新しいパスワードを入力すると設定または変更します。空欄なら現在の設定を維持。', @@ -4065,6 +4071,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Показывать предыдущие сеансы обмена сообщениями', settings_label_sync_insights: 'Синхронизировать с Insights', settings_label_check_updates: 'Проверять обновления', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Имя помощника по умолчанию', settings_label_password: 'Пароль доступа', @@ -4250,6 +4257,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Показывать предыдущие сеансы Discord, Telegram, Slack и Weixin, замененные сбросом или сжатием.', settings_desc_sync_insights: 'Синхронизирует использование токенов WebUI в state.db, чтобы Hermes /insights включал данные браузерных сеансов. Выключено по умолчанию.', settings_desc_check_updates: 'Показывает баннер, когда доступны более новые версии WebUI или Agent. Периодически выполняет git fetch в фоне.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Используется только для профиля по умолчанию. Другие профили используют свои имена.', settings_desc_password: 'Введите новый пароль, чтобы задать или изменить его. Оставьте пустым, чтобы сохранить текущую настройку.', @@ -5222,6 +5230,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostrar sesiones de mensajería anteriores', settings_label_sync_insights: 'Sincronizar con insights', settings_label_check_updates: 'Buscar actualizaciones', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nombre predeterminado del asistente', settings_label_password: 'Contraseña de acceso', @@ -5418,6 +5427,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Mostrar sesiones antiguas de Discord, Telegram, Slack y Weixin reemplazadas por reset o compresión.', settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.', settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Solo se usa para el perfil predeterminado. Los otros perfiles usan sus propios nombres.', settings_desc_password: 'Introduce una nueva contraseña para establecerla o cambiarla. Déjalo en blanco para mantener la configuración actual.', @@ -6372,6 +6382,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Vorherige Messaging-Sitzungen anzeigen', settings_label_sync_insights: 'Mit Insights synchronisieren', settings_label_check_updates: 'Nach Updates suchen', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Standard-Assistentenname', settings_label_password: 'Zugangspasswort', @@ -6558,6 +6569,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Zeigt ältere Discord-, Telegram-, Slack- und Weixin-Sitzungen, die durch Reset oder Compression ersetzt wurden.', settings_desc_sync_insights: 'Spiegelt den WebUI-Token-Verbrauch in die state.db, sodass hermes /insights Browser-Sitzungsdaten enthält. Standardmäßig aus.', settings_desc_check_updates: 'Zeigt ein Banner an, wenn neuere Versionen der WebUI oder des Agenten verfügbar sind. Führt regelmäßig einen Git-Fetch im Hintergrund aus.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Wird nur für das Standardprofil verwendet. Andere Profile verwenden ihre eigenen Namen.', settings_desc_password: 'Geben Sie ein neues Passwort ein, um es zu setzen oder zu ändern. Leer lassen, um die aktuelle Einstellung beizubehalten.', @@ -7574,6 +7586,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '显示以前的消息会话', settings_label_sync_insights: '同步到 insights', settings_label_check_updates: '检查更新', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: '默认助手名称', settings_label_password: '访问密码', @@ -7833,6 +7846,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: '显示被 reset 或 compression 替换的较旧的 Discord、Telegram、Slack 和 Weixin 会话。', settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。', settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: '仅用于默认个人资料。其他个人资料会使用各自的名称。', settings_desc_password: '输入新密码以设置或更改。留空保持当前设置。', @@ -8753,6 +8767,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '顯示以前的訊息對話', settings_label_sync_insights: '\u540c\u6b65\u5230 insights', settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: '預設助手名稱', settings_label_password: '\u8a2a\u554f\u5bc6\u78bc', @@ -8936,6 +8951,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: '顯示被 reset 或 compression 替換的較舊的 Discord、Telegram、Slack 和 Weixin 對話。', settings_desc_sync_insights: '將 WebUI token 使用情況同步到 state.db,使 hermes /insights 包含瀏覽器會話數據。預設未啟用。', settings_desc_check_updates: '當有更新的 WebUI 或助手版本時顯示標記。將在後台正常執行 Git-Fetch。', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: '僅用於預設個人檔案。其他個人檔案會使用各自的名稱。', settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002', @@ -10064,6 +10080,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostrar sessões de mensagens anteriores', settings_label_sync_insights: 'Sincronizar para insights', settings_label_check_updates: 'Verificar atualizações', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nome padrão do assistente', settings_label_password: 'Senha de Acesso', @@ -10253,6 +10270,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Mostrar sessões antigas de Discord, Telegram, Slack e Weixin substituídas por reset ou compressão.', settings_desc_sync_insights: 'Espelha uso de tokens para state.db.', settings_desc_check_updates: 'Mostrar banner quando versões mais novas estiverem disponíveis.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Usado apenas para o perfil padrão. Outros perfis usam seus próprios nomes.', settings_desc_password: 'Digite nova senha para definir ou trocar. Deixe em branco para manter.', @@ -11195,6 +11213,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '이전 메시징 세션 표시', settings_label_sync_insights: 'Insights에 동기화', settings_label_check_updates: '업데이트 확인', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: '기본 Assistant 이름', settings_label_password: '접근 비밀번호', @@ -11383,6 +11402,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'reset 또는 compression으로 교체된 이전 Discord, Telegram, Slack, Weixin 세션을 표시합니다.', settings_desc_sync_insights: 'WebUI 토큰 사용량을 state.db에 반영하여 hermes /insights에 브라우저 세션 데이터가 포함되도록 합니다. 기본값은 꺼짐입니다.', settings_desc_check_updates: 'WebUI 또는 Agent의 새 버전이 있으면 배너를 표시합니다. 백그라운드에서 주기적으로 git fetch를 실행합니다.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: '기본 프로필에만 사용됩니다. 다른 프로필은 각 프로필 이름을 사용합니다.', settings_desc_password: '새 비밀번호를 설정하거나 변경하려면 입력하세요. 현재 설정을 유지하려면 비워 두세요.', @@ -12339,6 +12359,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Afficher les sessions de messagerie précédentes', settings_label_sync_insights: 'Synchroniser avec les insights', settings_label_check_updates: 'Vérifier les mises à jour', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nom par défaut de l\'assistant', settings_label_password: 'Mot de passe d\'accès', @@ -12537,6 +12558,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Affichez les anciennes sessions Discord, Telegram, Slack et Weixin remplacées par reset ou compression.', settings_desc_sync_insights: 'Met en miroir l\'utilisation du jeton WebUI dans state.db afin que Hermes /insights inclut les données de session du navigateur. Désactivé par défaut.', settings_desc_check_updates: 'Afficher une bannière lorsque des versions plus récentes de WebUI ou de l\'agent sont disponibles. Exécute périodiquement une récupération git en arrière-plan.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Utilisé uniquement pour le profil par défaut. Les autres profils utilisent leurs propres noms.', settings_desc_password: 'Saisissez un nouveau mot de passe pour le définir ou le modifier. Laissez vide pour conserver le paramètre actuel.', diff --git a/static/index.html b/static/index.html index adb7502c36..29441d25c8 100644 --- a/static/index.html +++ b/static/index.html @@ -1179,6 +1179,13 @@

What can I help with?

Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.
+
+ +
Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.
+
+
Loading...
diff --git a/static/panels.js b/static/panels.js index fe034d4f0a..d1fa17e11a 100644 --- a/static/panels.js +++ b/static/panels.js @@ -428,9 +428,44 @@ function _cronDiagnostics(job) { return JSON.stringify(fields, null, 2); } +function _cronGatewayNoticeHtml(status) { + if (!status || (status.configured && status.running)) return ''; + const notConfigured = !status.configured; + const title = notConfigured + ? 'Gateway not configured' + : 'Gateway not running'; + const body = notConfigured + ? 'In Hermes WebUI, scheduled jobs require the Hermes gateway daemon. If this is a single-container Docker install, jobs can be created and run manually here, but scheduled ticks need a gateway container or `hermes gateway` running outside the WebUI.' + : 'In Hermes WebUI, scheduled jobs require the Hermes gateway daemon to be running. Start the gateway container or `hermes gateway` before relying on offline scheduled runs.'; + return ` +
${esc(title)}
+

${esc(body)}

+ `; +} + +async function loadCronGatewayNotice() { + const box = $('cronGatewayNotice'); + if (!box) return; + try { + const status = await api('/api/gateway/status'); + const html = _cronGatewayNoticeHtml(status); + if (html) { + box.innerHTML = html; + box.style.display = ''; + } else { + box.innerHTML = ''; + box.style.display = 'none'; + } + } catch (_) { + box.innerHTML = ''; + box.style.display = 'none'; + } +} + async function loadCrons(animate) { const box = $('cronList'); const refreshBtn = $('cronRefreshBtn'); + loadCronGatewayNotice(); if (animate && refreshBtn) { refreshBtn.style.opacity = '0.5'; refreshBtn.disabled = true; diff --git a/static/style.css b/static/style.css index 0f1354b96d..80f1e96e74 100644 --- a/static/style.css +++ b/static/style.css @@ -1109,6 +1109,7 @@ .panel-view.active{display:flex;} /* Cron panel */ .cron-list{flex:1;overflow-y:auto;padding:8px;} + .cron-gateway-notice{margin:8px 8px 0;} .cron-item{width:100%;min-width:0;box-sizing:border-box;border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);cursor:pointer;} .cron-item:hover{border-color:var(--border2);} .cron-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;} diff --git a/tests/test_issue2785_gateway_cron_guidance.py b/tests/test_issue2785_gateway_cron_guidance.py new file mode 100644 index 0000000000..0fe30477fe --- /dev/null +++ b/tests/test_issue2785_gateway_cron_guidance.py @@ -0,0 +1,37 @@ +"""Coverage for cron/gateway guidance in the Tasks panel and Docker docs.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +INDEX_HTML = ROOT / "static" / "index.html" +PANELS_JS = ROOT / "static" / "panels.js" +DOCKER_DOC = ROOT / "docs" / "docker.md" + + +def test_tasks_panel_has_gateway_notice_container(): + html = INDEX_HTML.read_text(encoding="utf-8") + + assert 'id="cronGatewayNotice"' in html + assert "detail-alert" in html + + +def test_cron_panel_loads_gateway_status_for_scheduling_guidance(): + panels = PANELS_JS.read_text(encoding="utf-8") + + assert "function _cronGatewayNoticeHtml" in panels + assert "function loadCronGatewayNotice" in panels + assert "api('/api/gateway/status')" in panels + assert "Gateway not configured" in panels + assert "Gateway not running" in panels + assert "scheduled jobs require the Hermes gateway daemon" in panels + assert "loadCronGatewayNotice()" in panels + + +def test_docker_docs_explain_single_container_cron_gateway_boundary(): + docs = DOCKER_DOC.read_text(encoding="utf-8") + + assert "single-container setup runs the WebUI only" in docs + assert "scheduled jobs require the Hermes gateway daemon" in docs + assert "Gateway not configured" in docs + assert "docker-compose.two-container.yml" in docs From 99c886c1992c8ec633c9259db28824c7afc3d046 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 24 May 2026 18:07:19 +0800 Subject: [PATCH 09/11] fix(workspace): open rendered preview links correctly --- api/routes.py | 49 +++++++++++++- static/index.html | 2 +- static/ui.js | 5 +- tests/test_issue2768_workspace_links.py | 88 +++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 tests/test_issue2768_workspace_links.py diff --git a/api/routes.py b/api/routes.py index 32ba020a65..edef7ed596 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6908,6 +6908,51 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_ return True +def _html_preview_with_blank_base(raw: bytes) -> bytes: + base = '' + text = raw.decode("utf-8", errors="replace") + if re.search(r"]*)?>", text, flags=re.IGNORECASE): + text = re.sub(r"(]*>)", r"\1" + base, text, count=1, flags=re.IGNORECASE) + elif re.search(r"]*>", text, flags=re.IGNORECASE): + text = re.sub( + r"(]*>)", + r"\1" + base + "", + text, + count=1, + flags=re.IGNORECASE, + ) + else: + text = "" + base + "" + text + return text.encode("utf-8") + + +def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp: str): + """Serve sandboxed workspace HTML preview with links targeting a new tab.""" + try: + body = _html_preview_with_blank_base(target.read_bytes()) + except PermissionError: + return bad(handler, "Permission denied", 403) + except Exception: + return bad(handler, "Could not read file", 500) + + handler.send_response(200) + handler.send_header("Content-Type", "text/html; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Accept-Ranges", "none") + handler.send_header("Cache-Control", cache_control) + handler.send_header("Content-Disposition", _content_disposition_value("inline", target.name)) + handler.send_header("Content-Security-Policy", csp) + handler.send_header("X-Content-Type-Options", "nosniff") + handler.send_header("Referrer-Policy", "same-origin") + handler.send_header( + "Permissions-Policy", + "camera=(), microphone=(self), geolocation=(), clipboard-write=(self)", + ) + handler.end_headers() + handler.wfile.write(body) + return True + + def _handle_media(handler, parsed): """Serve a local file by absolute path for inline display in the chat. @@ -7213,8 +7258,10 @@ def _handle_file_raw(handler, parsed): # CSP sandbox directive applies the same isolation server-side: without # allow-same-origin, the document is treated as a unique opaque origin and # cannot read WebUI cookies, localStorage, or postMessage to the parent. - csp = "sandbox allow-scripts" if html_inline_ok else None + csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox" if html_inline_ok else None # _serve_file_bytes sends Content-Security-Policy when csp is set. + if html_inline_ok: + return _serve_inline_html_preview(handler, target, "no-store", csp=csp) return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp) diff --git a/static/index.html b/static/index.html index fe54a92cb9..e551894313 100644 --- a/static/index.html +++ b/static/index.html @@ -1327,7 +1327,7 @@

What can I help with?

diff --git a/static/ui.js b/static/ui.js index c6a5cd2aa1..6679c87a92 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2889,7 +2889,7 @@ function renderMd(raw){ t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]); // Stash [label](url) links before autolink so the URL in href= is not re-linked const _link_stash=[]; - t=t.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); + t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)}${trail}`;}); t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]); t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]); @@ -2982,7 +2982,7 @@ function renderMd(raw){ // Stash existing tags first to avoid re-linking already-linked URLs. const _a_stash=[]; s=s.replace(/(]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;}); - s=s.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); + s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,label,url)=>`${esc(label)}`); s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]); // Restore raw
 only after markdown rewrites so literal preformatted
   // content stays placeholder-protected, then let the sanitizer normalize tags.
@@ -3016,6 +3016,7 @@ function renderMd(raw){
     if(!compact) return false;
     if(/^(javascript|data|vbscript):/i.test(compact)) return false;
     if(/^https?:\/\//i.test(raw)) return true;
+    if(/^(mailto:|tel:)/i.test(raw)) return true;
     if(img && /^api\//i.test(raw)) return true;
     if(!img && (/^api\//i.test(raw) || /^#/.test(raw))) return true;
     return false;
diff --git a/tests/test_issue2768_workspace_links.py b/tests/test_issue2768_workspace_links.py
new file mode 100644
index 0000000000..0733e200c0
--- /dev/null
+++ b/tests/test_issue2768_workspace_links.py
@@ -0,0 +1,88 @@
+import json
+import pathlib
+import re
+import subprocess
+import textwrap
+
+
+REPO = pathlib.Path(__file__).resolve().parents[1]
+UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
+INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
+ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
+
+
+def _extract_function(src: str, name: str) -> str:
+    marker = f"function {name}("
+    start = src.index(marker)
+    brace = src.index("{", start)
+    depth = 1
+    pos = brace + 1
+    while depth and pos < len(src):
+        ch = src[pos]
+        if ch == "{":
+            depth += 1
+        elif ch == "}":
+            depth -= 1
+        pos += 1
+    assert depth == 0, f"could not extract {name}()"
+    return src[start:pos]
+
+
+def _render(markdown: str) -> str:
+    js = textwrap.dedent(
+        r'''
+        const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+        const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
+        const _PDF_EXTS=/\.pdf$/i;
+        const _SVG_EXTS=/\.svg$/i;
+        const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm|oga)$/i;
+        const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
+        function t(k){ return k; }
+        function _mediaPlayerHtml(){ return ''; }
+        global.document={baseURI:'http://example.test/'};
+        '''
+    )
+    js += "\n" + _extract_function(UI_JS, "_matchBacktickFenceLine")
+    js += "\n" + _extract_function(UI_JS, "_isBacktickFenceClose")
+    js += "\n" + _extract_function(UI_JS, "renderMd")
+    js += textwrap.dedent(
+        r'''
+        const input=process.argv[1];
+        process.stdout.write(JSON.stringify(renderMd(input)));
+        '''
+    )
+    proc = subprocess.run(
+        ["node", "-e", js, markdown],
+        cwd=REPO,
+        text=True,
+        capture_output=True,
+        timeout=30,
+        check=True,
+    )
+    return json.loads(proc.stdout)
+
+
+def test_workspace_markdown_renders_mailto_and_tel_links():
+    html = _render("[email](mailto:foo@example.test) and [phone](tel:+15551212)")
+
+    assert 'email' in html
+    assert 'phone' in html
+
+
+def test_workspace_html_iframe_allows_links_to_escape_sandbox():
+    iframe = re.search(r']+id="previewHtmlIframe"[^>]*>', INDEX_HTML)
+
+    assert iframe, "previewHtmlIframe iframe not found"
+    sandbox = re.search(r'sandbox="([^"]+)"', iframe.group(0))
+    assert sandbox, "previewHtmlIframe must keep an explicit sandbox"
+    assert "allow-scripts" in sandbox.group(1)
+    assert "allow-popups" in sandbox.group(1)
+    assert "allow-popups-to-escape-sandbox" in sandbox.group(1)
+
+
+def test_file_raw_inline_html_preview_injects_base_target_blank():
+    raw_handler = ROUTES_PY[ROUTES_PY.index("def _handle_file_raw") :]
+
+    assert '' in ROUTES_PY
+    assert "_serve_inline_html_preview" in raw_handler
+    assert "html_inline_ok" in raw_handler

From 70402f96f166d35da715f4e49e08043d720a8b91 Mon Sep 17 00:00:00 2001
From: Frank Song 
Date: Sun, 24 May 2026 17:30:38 +0800
Subject: [PATCH 10/11] fix(workspace): fall back for large markdown previews

---
 static/workspace.js                           | 35 ++++++++-
 .../test_issue2823_large_markdown_preview.py  | 71 +++++++++++++++++++
 2 files changed, 105 insertions(+), 1 deletion(-)
 create mode 100644 tests/test_issue2823_large_markdown_preview.py

diff --git a/static/workspace.js b/static/workspace.js
index 5309addca7..b4c37ae114 100644
--- a/static/workspace.js
+++ b/static/workspace.js
@@ -175,6 +175,8 @@ const HTML_EXTS   = new Set(['.html','.htm']);
 const PDF_EXTS    = new Set(['.pdf']);
 const AUDIO_EXTS  = new Set(['.mp3','.wav','.m4a','.aac','.ogg','.oga','.opus','.flac']);
 const VIDEO_EXTS  = new Set(['.mp4','.mov','.m4v','.webm','.ogv','.avi','.mkv']);
+const MD_PREVIEW_RICH_RENDER_MAX_BYTES = 64 * 1024;
+const MD_PREVIEW_RICH_RENDER_MAX_LINES = 1500;
 // Binary formats that should download rather than preview
 const DOWNLOAD_EXTS = new Set([
   '.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
@@ -186,6 +188,31 @@ const DOWNLOAD_EXTS = new Set([
 
 function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
 
+function markdownPreviewByteLength(content){
+  const text=String(content||'');
+  if(typeof Blob==='function') return new Blob([text]).size;
+  if(typeof TextEncoder==='function') return new TextEncoder().encode(text).length;
+  return unescape(encodeURIComponent(text)).length;
+}
+
+function markdownPreviewLineCount(content){
+  const text=String(content||'');
+  if(!text) return 1;
+  return text.split('\n').length;
+}
+
+function shouldRenderMarkdownPreviewAsPlainText(content){
+  return markdownPreviewByteLength(content)>MD_PREVIEW_RICH_RENDER_MAX_BYTES
+    || markdownPreviewLineCount(content)>MD_PREVIEW_RICH_RENDER_MAX_LINES;
+}
+
+function largeMarkdownPlainTextStatus(content){
+  const bytes=markdownPreviewByteLength(content);
+  const lines=markdownPreviewLineCount(content);
+  const sizeLabel=bytes>=1024?`${Math.round(bytes/1024)} KB`:`${bytes} B`;
+  return `Large markdown file (${sizeLabel}, ${lines} lines) shown as plain text. Click Edit to view raw.`;
+}
+
 let _previewCurrentPath = '';  // relative path of currently previewed file
 let _previewCurrentMode = '';  // 'code' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video'
 let _previewDirty = false;     // true when edits are unsaved
@@ -317,8 +344,14 @@ async function openFile(path){
     // Markdown: fetch text, render with renderMd, display as formatted HTML
     try{
       const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
-      showPreview('md');
       _previewRawContent = data.content;
+      if(shouldRenderMarkdownPreviewAsPlainText(data.content)){
+        showPreview('code');
+        $('previewCode').textContent=data.content;
+        setStatus(largeMarkdownPlainTextStatus(data.content));
+        return;
+      }
+      showPreview('md');
       $('previewMd').innerHTML=renderMd(data.content);
       requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
     }catch(e){setStatus(t('file_open_failed'));}
diff --git a/tests/test_issue2823_large_markdown_preview.py b/tests/test_issue2823_large_markdown_preview.py
new file mode 100644
index 0000000000..e2821ed3b5
--- /dev/null
+++ b/tests/test_issue2823_large_markdown_preview.py
@@ -0,0 +1,71 @@
+"""Regression coverage for #2823 large Markdown workspace previews."""
+
+from pathlib import Path
+
+
+WORKSPACE_JS = Path("static/workspace.js").read_text(encoding="utf-8")
+
+
+def _open_file_block() -> str:
+    marker = "async function openFile(path){"
+    start = WORKSPACE_JS.find(marker)
+    assert start != -1, "openFile() not found in workspace.js"
+    end = WORKSPACE_JS.find("\nfunction downloadFile", start)
+    assert end != -1, "downloadFile() marker not found after openFile()"
+    return WORKSPACE_JS[start:end]
+
+
+def _markdown_branch() -> str:
+    block = _open_file_block()
+    start = block.find("} else if(MD_EXTS.has(ext)){")
+    assert start != -1, "Markdown preview branch not found in openFile()"
+    end = block.find("} else if(HTML_EXTS.has(ext)){", start)
+    assert end != -1, "HTML preview branch marker not found after Markdown branch"
+    return block[start:end]
+
+
+def test_large_markdown_preview_limits_are_source_controlled():
+    assert "MD_PREVIEW_RICH_RENDER_MAX_BYTES = 64 * 1024" in WORKSPACE_JS
+    assert "MD_PREVIEW_RICH_RENDER_MAX_LINES = 1500" in WORKSPACE_JS
+    assert "function shouldRenderMarkdownPreviewAsPlainText(content)" in WORKSPACE_JS
+
+
+def test_large_markdown_fallback_sets_raw_content_before_size_gate():
+    branch = _markdown_branch()
+    raw_pos = branch.find("_previewRawContent = data.content")
+    gate_pos = branch.find("shouldRenderMarkdownPreviewAsPlainText(data.content)")
+    fallback_pos = branch.find("showPreview('code')")
+    rich_pos = branch.find("showPreview('md')")
+
+    assert raw_pos != -1, "Markdown preview must retain raw text for Edit mode"
+    assert gate_pos != -1, "Markdown preview must guard rich rendering by size/line count"
+    assert fallback_pos != -1, "Large Markdown preview must fall back to plain text"
+    assert rich_pos != -1, "Small Markdown preview must still use rich Markdown mode"
+    assert raw_pos < gate_pos < fallback_pos < rich_pos
+
+
+def test_large_markdown_fallback_uses_code_view_without_rich_render_or_katex():
+    branch = _markdown_branch()
+    gate_pos = branch.find("if(shouldRenderMarkdownPreviewAsPlainText(data.content)){")
+    fallback_end = branch.find("return;", gate_pos)
+    assert gate_pos != -1 and fallback_end != -1, "Large Markdown fallback block not found"
+
+    fallback = branch[gate_pos:fallback_end]
+    compact = fallback.replace(" ", "")
+    assert "$('previewCode').textContent=data.content" in compact
+    assert "setStatus(" in fallback
+    assert "renderMd(" not in fallback
+    assert "renderKatexBlocks" not in fallback
+
+
+def test_small_markdown_still_renders_and_runs_katex_after_render():
+    branch = _markdown_branch()
+    fallback_end = branch.find("return;")
+    assert fallback_end != -1, "Large Markdown fallback must return before rich rendering"
+
+    rich = branch[fallback_end:]
+    render_pos = rich.find("$('previewMd').innerHTML=renderMd(data.content)")
+    katex_pos = rich.rfind("renderKatexBlocks")
+    assert render_pos != -1, "Small Markdown files must still rich-render with renderMd()"
+    assert katex_pos != -1, "Small Markdown file previews must still trigger KaTeX rendering"
+    assert katex_pos > render_pos

From ded516754b1699b900d14bb3d2cdb6a7b6ad4f65 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sun, 24 May 2026 15:55:03 +0000
Subject: [PATCH 11/11] Stamp CHANGELOG for v0.51.125 (Release CW /
 stage-batch7 / 10-PR low-risk batch)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Cherry-picked PRs:
- #2839 (tn801534) — kanban worker log URL double query param fix
- #2832 (franksong2702) — tolerate malformed request logging
- #2818 (humayunak) — prevent focus theft by approval/clarify cards
- #2820 (tangerine-fan) — echo clarify user choice as visible message
- #2826 (Koraji95-coder) — chip wrap overlap fix at narrow widths (closes #2740)
- #2843 (AJV20) — Settings option to ignore Agent updates
- #2837 (franksong2702) — clarify CSRF rejection diagnostics
- #2838 (franksong2702) — surface gateway scheduling guidance in Tasks panel
- #2834 (franksong2702) — render mailto:/tel: links + sandbox HTML preview links
- #2829 (franksong2702) — large markdown preview falls back to plain text (closes #2823, supersedes #2828)
---
 CHANGELOG.md | 26 +++++++++++++++++++++++---
 1 file changed, 23 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a1b6db3b9..c144ceb58a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,11 +3,31 @@
 
 ## [Unreleased]
 
+## [v0.51.125] — 2026-05-24 — Release CW (stage-batch7 — 10-PR low-risk batch — UI/UX polish + bug fixes + diagnostics)
+
 ### Fixed
 
-- Malformed HTTP request logging now falls back to `"-"` for missing method or
-  path fields instead of raising an `AttributeError` traceback while handling
-  the 400 response.
+- **PR #2839** by @tn801534 — Kanban worker log endpoint constructed URLs with a double query string (`?board=?tail=65536`) when a non-default board was active. The frontend was appending `?tail=65536` directly to a URL that already had `?board=...` from `_kanbanBoardQuery()`. Fix: pass `{tail: 65536}` as the `extra` argument to `_kanbanBoardQuery()` so it composes both params into a single valid query string. One-line, narrow scope.
+
+- **PR #2832** by @franksong2702 — Malformed HTTP request logging in `server.py` falls back to `"-"` for missing `command` or `path` instead of raising `AttributeError`. Defensive `getattr(self, 'command', None) or '-'` matches the pattern already used for `_req_t0` elsewhere in the handler. Adds `tests/test_issue2775_log_request.py` covering the malformed-request-before-path-assigned case.
+
+- **PR #2818** by @humayunak — Approval and clarify cards no longer steal focus from the composer textarea (`#msg`) when the user is mid-type. `showApprovalCard()` and `showClarifyCard()` now guard the `focus()` call on `document.activeElement !== $('msg')`, matching the pattern already used elsewhere for focus-sensitive paths. The clarify card also moves the focus call out of `setTimeout` for snappier UX. Silently dropped keystrokes during streaming are eliminated.
+
+- **PR #2826** by @Koraji95-coder — Composer footer chip wraps no longer overlap at narrow widths (closes #2740). The five chip wraps (`.composer-profile-wrap`, `.composer-ws-wrap`, `.composer-model-wrap`, `.composer-reasoning-wrap`, `.composer-toolsets-wrap`) had `flex: 0 1 auto` + `min-width: 0` so they would compress past their content's natural width when the composer narrowed, causing visual overlap of the profile / workspace / model / reasoning chips. Switched to `flex: 0 0 auto` via a single grouped selector. Each chip now keeps its natural width and the existing `overflow-x: auto` on `.composer-left` handles overflow via horizontal scroll. Default-width layout unchanged; only affects the overflow regime. Mobile-specific rules (already `flex: 0 0 auto`) untouched.
+
+- **PR #2829** by @franksong2702 — Workspace Markdown previews fall back to plain text for very large files (>64 KB or >1500 lines) instead of synchronously running the full rich Markdown renderer on the browser main thread, which could lock up the tab for several seconds on multi-megabyte `.md` files. Plain-text preview shows file size + line count in the status line so users know why rich rendering was bypassed; Edit mode still shows raw content as before. Closes #2823. Supersedes #2828 (same scope, less polished).
+
+- **PR #2837** by @franksong2702 — CSRF rejections now distinguish origin/proxy mismatches from expired session tokens, so provider-key removal and other protected requests show actionable diagnostics instead of the generic "Cross-origin request rejected" error. Adds `tests/test_issue2572_csrf_diagnostics.py` covering both failure modes.
+
+- **PR #2834** by @franksong2702 — Workspace Markdown `mailto:` and `tel:` links now render as clickable links, and sandboxed HTML preview links open outside the iframe (via injected ``) instead of navigating the preview into a browser-blocked page. Adds `tests/test_issue2768_workspace_links.py`.
+
+- **PR #2838** by @franksong2702 — Tasks panel surfaces a warning when the Hermes gateway is not configured or not running, so Docker users know scheduled jobs need the gateway daemon to tick while away. The single-container Docker boundary is also clarified in `docs/docker.md`. Adds `tests/test_issue2785_gateway_cron_guidance.py`.
+
+### Added
+
+- **PR #2820** by @tangerine-fan — Clarify user choice is now echoed as a visible message in the conversation transcript. After the user responds to a clarify prompt, a synthetic user message with the chosen value is inserted into `S.messages` (marked `_clarify_response: true` so downstream consumers can filter if needed). Previously the choice was only visible in the transient clarify card; now the chat history preserves the decision.
+
+- **PR #2843** by @AJV20 — New Settings preference "Ignore Agent updates" keeps WebUI update notices, banners, and update actions enabled while suppressing Hermes Agent update checks. Default `False` (current behavior). Useful when running an unreleased agent build or pinning to a specific agent commit.
 
 ## [v0.51.124] — 2026-05-24 — Release CV (stage-batch6 — 3-PR Windows-only stack — agent paths / docs / port hardening)