Merge pull request #1923 from nesquena/stage-322

v0.51.27 — Release E1: 4-PR batch (workspace-prefix sentinel hardening, custom named provider API key resolution, streaming chat scroll-pin, Kanban detail scrollable)
This commit is contained in:
nesquena-hermes
2026-05-08 10:09:32 -07:00
committed by GitHub
16 changed files with 416 additions and 37 deletions
+38
View File
@@ -1,5 +1,43 @@
# Hermes Web UI -- Changelog
## [v0.51.27] — 2026-05-08 — 4-PR contributor batch (Release E1: workspace-prefix sentinel hardening, custom named provider API key resolution, streaming chat scroll-pin retention, Kanban detail scrollable)
### Fixed (4 PRs)
- **PR #1916** by @Michaelyklam — Make Kanban detail view scrollable. The app shell sets `body { overflow: hidden }`, so the Kanban main view must own vertical scrolling. Pre-fix, a selected task with a long body could push the board below the viewport with no way to reach it. Fix: add `overflow-y: auto` to `main.main.showing-kanban > #mainKanban` (one CSS property + regression test). Closes #1915.
- **PR #1914** by @ai-ag2026 — Keep streaming chat pinned after final render. During streaming, bottom-pinned scroll worked, but after the `done` event late Markdown layout growth could unpin the viewport — the user would see the last token, then suddenly the chat would scroll up by hundreds of pixels as render reflowed. Fix: add explicit upward-intent gating (`MESSAGE_UPWARD_INTENT_MS=450` ms window for wheel/touch events) so passive `scrollTop` decreases from windowing/reflow no longer count as user upward intent. Pre-replacement `shouldFollowOnDone` capture in `static/messages.js` calls `scrollToBottom()` if pin or near-bottom (`<=1200px`) was true before render. `scrollIfPinned` and `scrollToBottom` now write `_lastScrollTop` and clear the programmatic flag in a rAF so the next listener pass doesn't see a synthetic upward delta.
- **PR #1918** by @franksong2702 — Fix workspace prefix sentinel handling (closes #1913 follow-up filed in v0.51.25). The pre-fix strip regex `^\s*\[Workspace:[^\]]+\]\s*` was too permissive — a user prompt starting with `[Workspace: /path/to/explain]` would be silently eaten, and workspace paths containing `]` would truncate at the first `]`. Fix introduces a versioned sentinel format `[Workspace::v1: ...]` (double-colon distinguishes from natural English) AND escapes `]` in the path with `\]`. New helpers: `_workspace_context_prefix(path)`, `_escape_workspace_prefix_path(path)`, and `_strip_workspace_prefix(text, *, include_legacy=False)` with optional legacy fallback for transcript-compaction identity matching during the migration window. Closes #1913.
**Mid-stage absorbed fixes (per Opus advisor on stage-322):**
1. **#1918 missed second injection site at `api/routes.py:6689`** (`_handle_chat_sync`, the `POST /api/chat` synchronous handler). Without this fix, the sync chat path would still inject legacy `[Workspace: ...]` while the streaming path injected `[Workspace::v1: ...]` — producing user bubbles that visibly leak the prefix on the sync surface, and a system-prompt format string that no longer matches reality. Maintainer routed the sync injection through `_workspace_context_prefix(...)` and updated the surrounding system-prompt text to v1 form, mirroring the streaming.py block.
2. **#1918 backwards-compat gap in `static/ui.js:_stripWorkspaceDisplayPrefix`** — existing on-disk transcripts saved before the v1 migration still carry the legacy format. Without a JS legacy fallback, pre-upgrade sessions would render the literal `[Workspace: /tmp/proj]` prefix in user bubbles after upgrade. Maintainer added a legacy-regex fallback paralleling the Python `include_legacy=True` branch on the streaming side; updated the regression test that previously asserted the legacy regex was absent.
- **PR #1814** by @hualong1009 — Custom named provider API key resolution. Adds new top-level helper `resolve_custom_provider_connection(provider_id) -> (api_key, base_url)` that resolves `custom:*` provider IDs to credentials from `config.yaml > custom_providers[]`. Supports `api_key` as literal value, `${ENV_VAR}` interpolation, or `key_env` env-var hint. Uses `get_config()` snapshot (per-profile aware). Fallback to single-entry `custom_providers` when slug doesn't match exactly. Also adds fallback in `api/streaming.py` self-heal paths so an agent rebuild after a transient failure can re-fetch credentials. **Deferral re-evaluated (per prior sweep notes):** the prior `maintainer-review` flag noted feared overlap with #1818, but #1818 already shipped (v0.51.19) with its slug-matching helpers. Re-checking against current master post-#1818: the new `resolve_custom_provider_connection()` is purely additive (no helper duplication). **Style observation (non-blocking)**: PR's local `_slugify` has slightly different normalization (`_``-`, collapse `--`, strip leading/trailing `-`) than master's canonical `_custom_provider_slug_from_name`. Internally self-consistent (both pid and entry name go through the same local slugify before comparison) so it works for matching, but a follow-up could unify the slug semantics. The 6-call-site fallback pattern (3 in `api/routes.py`, 3 in `api/streaming.py`) is also a candidate for a single `apply_custom_provider_fallback()` helper.
### Tests
4890 → **4898 collected, 4884 passing, 0 regressions** (+8 net new). Full suite ~145s on Python 3.11 (HERMES_HOME isolated). JS syntax check (`node -c`) passes on `static/messages.js` and `static/ui.js`. Browser API sanity harness (port 8789) all-green: 11 endpoints + 20 QA tests verified. Opus advisor pass: 2 BLOCKERS identified and fixed in-stage (per absorb-in-release default), then SHIP.
### Pre-release verification
- Full pytest under `HERMES_HOME` isolation: **4884 passed, 11 skipped, 1 xfailed, 2 xpassed, 8 subtests passed** in 145.18s.
- Browser API harness against stage-322 on port 8789: all 11 endpoints + 20 QA tests PASS.
- `node -c` on `static/messages.js`, `static/ui.js`: clean.
- Stage diff: 13 files, +348/-22 (pre-Opus-fix); 14 files, +382/-31 (post-Opus-fix incorporating the routes.py legacy-injection fix and ui.js legacy-fallback fix).
- Opus advisor pass on stage-322 brief: identified 2 BLOCKERS in PR #1918 (missed `routes.py` injection site + missing JS legacy fallback). Both absorbed in-stage per absorb-in-release default. Test that asserted "legacy regex absent" updated to assert legacy regex IS present (mirrors Python `include_legacy=True` branch).
- v0.51.26 fixes verified preserved across rebase: `_strip_workspace_prefix` (10), `on_interim_assistant` (2), `_max_iterations_cfg` (9), `if input_tokens > 0:` (1), `get_default_hermes_root` (3), `_sessionSegmentCount` (9), `_active_skills_dir` (6).
- Pre-stamp re-fetch of all 4 PR heads: no contributor force-pushes during the Opus window.
### Opus-applied fixes (absorbed in-release)
**From stage-322 absorption:**
1. **#1918 second injection site** — `api/routes.py:_handle_chat_sync` was injecting legacy `[Workspace: ...]` and telling the agent that's the active format. Fixed: routed through `_workspace_context_prefix(str(s.workspace))`; updated surrounding system-prompt strings to reference `[Workspace::v1: ...]` consistently.
2. **#1918 JS legacy fallback** — `static/ui.js:_stripWorkspaceDisplayPrefix` was changed to v1-only regex with no legacy fallback. Fixed: added fallthrough to legacy regex when v1 strip doesn't match, mirroring the Python `include_legacy=True` branch. Updated test `test_workspace_display_prefix_helper_strips_leading_metadata_only` to assert the legacy regex IS present (was inverted to assert it was absent).
## [v0.51.26] — 2026-05-08 — 5-PR follow-on contributor batch (Release D: profile-isolation hardening across cache + skills + gateway-health, context-length config-override threading, sidebar segment count UI polish)
### Fixed (5 PRs + 1 absorbed test)
+1 -1
View File
@@ -2,7 +2,7 @@
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
>
> Last updated: v0.51.26 (May 8, 2026) — 4890 tests collected — 5-PR Release D batch (gateway health root home, sidebar segment count, skills profile scoping, profile-home cache signature, context-length config overrides)
> Last updated: v0.51.27 (May 8, 2026) — 4898 tests collected — 4-PR Release E1 batch (workspace-prefix sentinel hardening, custom named provider API key resolution, streaming chat scroll-pin, Kanban detail scrollable)
> Test source: `pytest tests/ --collect-only -q`
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
+2 -2
View File
@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
---
*Last updated: v0.51.26, May 8, 2026*
*Total automated tests collected: 4890*
*Last updated: v0.51.27, May 8, 2026*
*Total automated tests collected: 4898*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*
+96
View File
@@ -1596,6 +1596,102 @@ def resolve_model_provider(model_id: str) -> tuple:
return model_id, config_provider, config_base_url
def resolve_custom_provider_connection(provider_id: str) -> tuple[str | None, str | None]:
"""Return (api_key, base_url) for a named ``custom:*`` provider.
Supports ``custom_providers[].api_key`` as either a literal key or
``${ENV_VAR}``, and ``custom_providers[].key_env`` as an env-var hint.
Returns ``(None, None)`` when no named custom provider matches.
"""
pid = str(provider_id or "").strip().lower()
if not pid.startswith("custom:"):
return None, None
def _slugify(value: str) -> str:
s = str(value or "").strip().lower().replace("_", "-").replace(" ", "-")
while "--" in s:
s = s.replace("--", "-")
return s.strip("-")
slug = _slugify(pid.split(":", 1)[1].strip())
if not slug:
return None, None
# Read the live config snapshot to avoid stale module-level cache edge
# cases after profile switches or runtime config edits.
cfg_data = get_config()
def _resolve_key(raw_api_key, raw_key_env) -> str | None:
api_key = None
if raw_api_key is not None:
key_text = str(raw_api_key).strip()
if key_text.startswith("${") and key_text.endswith("}") and len(key_text) > 3:
api_key = os.getenv(key_text[2:-1], "").strip() or None
elif key_text:
api_key = key_text
if not api_key:
key_env = str(raw_key_env or "").strip()
if key_env:
api_key = os.getenv(key_env, "").strip() or None
return api_key
custom_providers = cfg_data.get("custom_providers", [])
if not isinstance(custom_providers, list):
custom_providers = []
for entry in custom_providers:
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "").strip()
if not name:
continue
entry_slug = _slugify(name)
if entry_slug != slug:
continue
base_url = str(entry.get("base_url") or "").strip() or None
api_key = _resolve_key(entry.get("api_key"), entry.get("key_env"))
return api_key, base_url
# If exactly one custom provider is configured, use it as a pragmatic
# fallback for mismatched slugs (e.g. punctuation differences).
if len(custom_providers) == 1 and isinstance(custom_providers[0], dict):
entry = custom_providers[0]
return (
_resolve_key(entry.get("api_key"), entry.get("key_env")),
str(entry.get("base_url") or "").strip() or None,
)
# Fallbacks for setups that don't use custom_providers names directly.
providers_cfg = cfg_data.get("providers", {})
provider_specific = providers_cfg.get(pid, {}) if isinstance(providers_cfg, dict) else {}
provider_custom = providers_cfg.get("custom", {}) if isinstance(providers_cfg, dict) else {}
model_cfg = cfg_data.get("model", {})
model_provider = str(model_cfg.get("provider") or "").strip().lower() if isinstance(model_cfg, dict) else ""
fallback_base = None
for candidate in (provider_specific, provider_custom, model_cfg):
if isinstance(candidate, dict):
_base = str(candidate.get("base_url") or "").strip()
if _base:
fallback_base = _base
break
fallback_key = None
if isinstance(provider_specific, dict):
fallback_key = _resolve_key(provider_specific.get("api_key"), provider_specific.get("key_env"))
if not fallback_key and isinstance(provider_custom, dict):
fallback_key = _resolve_key(provider_custom.get("api_key"), provider_custom.get("key_env"))
if not fallback_key and isinstance(model_cfg, dict) and model_provider in {"custom", pid, slug}:
fallback_key = _resolve_key(model_cfg.get("api_key"), model_cfg.get("key_env"))
if fallback_key or fallback_base:
return fallback_key, fallback_base or None
return None, None
def model_with_provider_context(model_id: str, model_provider: str | None = None) -> str:
"""Return the model string to pass to ``resolve_model_provider()``.
+37 -13
View File
@@ -6639,7 +6639,10 @@ def _handle_chat_sync(handler, body):
from run_agent import AIAgent
with CHAT_LOCK:
from api.config import resolve_model_provider
from api.config import (
resolve_model_provider,
resolve_custom_provider_connection,
)
_model, _provider, _base_url = resolve_model_provider(
model_with_provider_context(s.model, getattr(s, "model_provider", None))
@@ -6665,6 +6668,12 @@ def _handle_chat_sync(handler, body):
f"[webui] WARNING: resolve_runtime_provider failed: {_e}",
flush=True,
)
if isinstance(_provider, str) and _provider.startswith("custom:"):
_cp_key, _cp_base = resolve_custom_provider_connection(_provider)
if not _api_key and _cp_key:
_api_key = _cp_key
if not _base_url and _cp_base:
_base_url = _cp_base
agent = AIAgent(
model=_model,
provider=_provider,
@@ -6677,23 +6686,24 @@ def _handle_chat_sync(handler, body):
enabled_toolsets=_resolve_cli_toolsets(),
session_id=s.session_id,
)
workspace_ctx = f"[Workspace: {s.workspace}]\n"
workspace_system_msg = (
f"Active workspace at session start: {s.workspace}\n"
"Every user message is prefixed with [Workspace: /absolute/path] indicating the "
"workspace the user has selected in the web UI at the time they sent that message. "
"This tag is the single authoritative source of the active workspace and updates "
"with every message. It overrides any prior workspace mentioned in this system "
"prompt, memory, or conversation history. Always use the value from the most recent "
"[Workspace: ...] tag as your default working directory for ALL file operations: "
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present."
)
from api.streaming import (
_merge_display_messages_after_agent_result,
_restore_reasoning_metadata,
_sanitize_messages_for_api,
_session_context_messages,
_workspace_context_prefix,
)
workspace_ctx = _workspace_context_prefix(str(s.workspace))
workspace_system_msg = (
f"Active workspace at session start: {s.workspace}\n"
"Every user message is prefixed with [Workspace::v1: /absolute/path] indicating the "
"workspace the user has selected in the web UI at the time they sent that message. "
"This tag is the single authoritative source of the active workspace and updates "
"with every message. It overrides any prior workspace mentioned in this system "
"prompt, memory, or conversation history. Always use the value from the most recent "
"[Workspace::v1: ...] tag as your default working directory for ALL file operations: "
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present."
)
_previous_messages = list(s.messages or [])
@@ -7427,6 +7437,13 @@ def _handle_session_compress(handler, body):
except Exception as _e:
logger.warning("resolve_runtime_provider failed for compression: %s", _e)
if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"):
_cp_key, _cp_base = _cfg.resolve_custom_provider_connection(resolved_provider)
if not resolved_api_key and _cp_key:
resolved_api_key = _cp_key
if not resolved_base_url and _cp_base:
resolved_base_url = _cp_base
if not resolved_api_key:
return bad(handler, "No provider configured -- cannot compress.")
@@ -8041,6 +8058,13 @@ def _handle_handoff_summary(handler, body):
except Exception as _e:
logger.warning("resolve_runtime_provider failed for handoff summary: %s", _e)
if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"):
_cp_key, _cp_base = _cfg.resolve_custom_provider_connection(resolved_provider)
if not resolved_api_key and _cp_key:
resolved_api_key = _cp_key
if not resolved_base_url and _cp_base:
resolved_base_url = _cp_base
if not resolved_api_key:
summary_text = _fallback_handoff_summary(msgs)
try:
+53 -9
View File
@@ -26,6 +26,7 @@ from api.config import (
_get_session_agent_lock, _set_thread_env, _clear_thread_env,
SESSION_AGENT_LOCKS, SESSION_AGENT_LOCKS_LOCK,
resolve_model_provider,
resolve_custom_provider_connection,
model_with_provider_context,
)
from api.helpers import redact_session_data, _redact_text
@@ -586,9 +587,25 @@ def _message_text(value) -> str:
return _strip_thinking_markup(str(value or '').strip())
def _strip_workspace_prefix(text: str) -> str:
"""Remove WebUI's model-facing workspace tag from display identity text."""
return re.sub(r'^\s*\[Workspace:[^\]]+\]\s*', '', str(text or '')).strip()
_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*')
_LEGACY_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace:[^\]]+\]\s*')
def _escape_workspace_prefix_path(path: str) -> str:
return str(path or '').replace('\\', '\\\\').replace(']', '\\]')
def _workspace_context_prefix(path: str) -> str:
return f"[Workspace::v1: {_escape_workspace_prefix_path(path)}]\n"
def _strip_workspace_prefix(text: str, *, include_legacy: bool = False) -> str:
"""Remove WebUI-injected workspace tags without eating user-typed text."""
value = str(text or '')
stripped = _WORKSPACE_PREFIX_RE.sub('', value, count=1)
if include_legacy and stripped == value:
stripped = _LEGACY_WORKSPACE_PREFIX_RE.sub('', value, count=1)
return stripped.strip()
def _first_exchange_snippets(messages):
@@ -1051,7 +1068,7 @@ def _fallback_title_from_exchange(user_text: str, assistant_text: str) -> Option
assistant_text = _strip_thinking_markup(assistant_text or '').strip()
if not user_text:
return None
user_text = re.sub(r'^\[Workspace:[^\]]+\]\s*', '', user_text)
user_text = _strip_workspace_prefix(user_text)
user_text = re.sub(r'\s+', ' ', user_text).strip()
assistant_text = re.sub(r'\s+', ' ', assistant_text).strip()
combined = f"{user_text} {assistant_text}".strip().lower()
@@ -1443,7 +1460,7 @@ def _message_identity(msg):
# visible optimistic bubble contains only the human text. Treat them as
# the same turn for merge/dedup purposes; otherwise compaction results
# render two adjacent user bubbles ("Ok" and "[Workspace...]\nOk").
text = _strip_workspace_prefix(text)
text = _strip_workspace_prefix(text, include_legacy=True)
if not text and not msg.get('tool_call_id') and not msg.get('tool_calls'):
return None
return (
@@ -1482,7 +1499,12 @@ def _find_current_user_turn(messages, msg_text):
if not isinstance(msg, dict) or msg.get('role') != 'user':
continue
fallback = idx
text = " ".join(_strip_workspace_prefix(_message_text(msg.get('content', ''))).split())
text = " ".join(
_strip_workspace_prefix(
_message_text(msg.get('content', '')),
include_legacy=True,
).split()
)
if needle and (needle in text or text in needle):
return idx
return fallback
@@ -2266,6 +2288,16 @@ def _run_agent_streaming(
except Exception as _e:
print(f"[webui] WARNING: resolve_runtime_provider failed: {_e}", flush=True)
# Named custom providers (custom:slug) may not be resolvable by
# hermes_cli.runtime_provider directly. Fall back to config.yaml
# custom_providers[] so WebUI can pass explicit creds/base_url.
if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"):
_cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider)
if not resolved_api_key and _cp_key:
resolved_api_key = _cp_key
if not resolved_base_url and _cp_base:
resolved_base_url = _cp_base
# Read per-profile config at call time (not module-level snapshot)
from api.config import get_config as _get_config
_cfg = _get_config()
@@ -2534,15 +2566,15 @@ def _run_agent_streaming(
# Prepend workspace context so the agent always knows which directory
# to use for file operations, regardless of session age or AGENTS.md defaults.
workspace_ctx = f"[Workspace: {s.workspace}]\n"
workspace_ctx = _workspace_context_prefix(str(s.workspace))
workspace_system_msg = (
f"Active workspace at session start: {s.workspace}\n"
"Every user message is prefixed with [Workspace: /absolute/path] indicating the "
"Every user message is prefixed with [Workspace::v1: /absolute/path] indicating the "
"workspace the user has selected in the web UI at the time they sent that message. "
"This tag is the single authoritative source of the active workspace and updates "
"with every message. It overrides any prior workspace mentioned in this system "
"prompt, memory, or conversation history. Always use the value from the most recent "
"[Workspace: ...] tag as your default working directory for ALL file operations: "
"[Workspace::v1: ...] tag as your default working directory for ALL file operations: "
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present."
)
@@ -2725,6 +2757,12 @@ def _run_agent_streaming(
resolved_provider = _heal_rt.get('provider')
if not resolved_base_url:
resolved_base_url = _heal_rt.get('base_url')
if isinstance(resolved_provider, str) and resolved_provider.startswith('custom:'):
_cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider)
if not resolved_api_key and _cp_key:
resolved_api_key = _cp_key
if not resolved_base_url and _cp_base:
resolved_base_url = _cp_base
# Rebuild agent kwargs and create a fresh agent
_agent_kwargs['api_key'] = resolved_api_key
_agent_kwargs['base_url'] = resolved_base_url
@@ -3284,6 +3322,12 @@ def _run_agent_streaming(
resolved_provider = _heal_rt.get('provider')
if not resolved_base_url:
resolved_base_url = _heal_rt.get('base_url')
if isinstance(resolved_provider, str) and resolved_provider.startswith('custom:'):
_cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider)
if not resolved_api_key and _cp_key:
resolved_api_key = _cp_key
if not resolved_base_url and _cp_base:
resolved_base_url = _cp_base
# Build a fresh agent with the new credentials
_heal_kwargs = dict(_agent_kwargs) if '_agent_kwargs' in dir() else {}
_heal_kwargs['api_key'] = resolved_api_key
+6 -1
View File
@@ -915,6 +915,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
const shouldFollowOnDone=isActiveSession&&((typeof _shouldFollowMessagesOnDomReplace==='function')
? _shouldFollowMessagesOnDomReplace()
: (typeof _isMessagePaneNearBottom==='function'&&_isMessagePaneNearBottom(1200)));
if(isActiveSession){
S.activeStreamId=null;
}
@@ -994,7 +997,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// No-reply guard (#373): if agent returned nothing, show inline error
if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});}
if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length);
syncTopbar();renderMessages({preserveScroll:true});loadDir('.');
syncTopbar();renderMessages({preserveScroll:true});
if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
loadDir('.');
// TTS auto-read: speak the last assistant response if enabled (#499)
if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
}
+1 -1
View File
@@ -2275,7 +2275,7 @@ main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;}
main.main.showing-skills > #mainSkills{display:flex;}
main.main.showing-memory > #mainMemory{display:flex;}
main.main.showing-tasks > #mainTasks{display:flex;}
main.main.showing-kanban > #mainKanban{display:flex;}
main.main.showing-kanban > #mainKanban{display:flex;overflow-y:auto;}
main.main.showing-workspaces > #mainWorkspaces{display:flex;}
main.main.showing-profiles > #mainProfiles{display:flex;}
main.main.showing-logs > #mainLogs{display:flex;}
+47 -7
View File
@@ -70,7 +70,15 @@ function _isBacktickFenceClose(line,minLen){
*/
function _stripWorkspaceDisplayPrefix(text){
return String(text||'').replace(/^\s*\[Workspace:[^\]]+\]\s*/,'').trim();
// v1 sentinel format `[Workspace::v1: <escaped path>]\n` injected since #1918.
// Legacy format `[Workspace: <path>]\n` may still be present in transcripts
// saved before the v1 migration; fall through to the legacy regex when the
// v1 strip didn't match. Mirrors the Python `include_legacy=True` branch in
// api/streaming.py:_strip_workspace_prefix(). Per Opus advisor on stage-322.
const value = String(text||'');
const stripped = value.replace(/^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*/,'');
if(stripped !== value) return stripped.trim();
return value.replace(/^\s*\[Workspace:[^\]]+\]\s*/,'').trim();
}
function _renderUserFencedBlocks(text){
const stash=[];
@@ -1500,7 +1508,10 @@ let _programmaticScroll=false;
let _nearBottomCount=0;
let _lastScrollTop=null;
let _lastNonMessageScrollIntentMs=0;
let _lastMessageUpwardIntentMs=0;
let _messageUserUnpinned=false;
const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350;
const MESSAGE_UPWARD_INTENT_MS=450;
function _recordNonMessageScrollIntent(e){
const el=document.getElementById('messages');
const target=e&&e.target;
@@ -1510,6 +1521,18 @@ function _recordNonMessageScrollIntent(e){
// session sidebar (or another independent pane) must not be immediately fought
// by scrollIfPinned() writing #messages.scrollTop on the next token (#1784).
if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now();
else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY<0)){
// User is intentionally moving upward in the transcript. Record the real
// input event so later scrollTop decreases caused by layout/windowing do
// not masquerade as user intent and strand live streaming away from bottom.
_lastMessageUpwardIntentMs=performance.now();
_messageUserUnpinned=true;
_nearBottomCount=0;
_scrollPinned=false;
}
}
function _recentMessageUpwardIntent(){
return performance.now()-_lastMessageUpwardIntentMs<MESSAGE_UPWARD_INTENT_MS;
}
function _recentNonMessageScrollIntent(){
return performance.now()-_lastNonMessageScrollIntentMs<NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS;
@@ -1533,10 +1556,11 @@ if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetS
_scrollRaf=requestAnimationFrame(()=>{
const top=el.scrollTop;
const nearBottom=el.scrollHeight-top-el.clientHeight<250;
const movedUp=_lastScrollTop!==null && top<_lastScrollTop-2;
// scrollToBottomBtn visibility is updated below after pin state settles.
const movedUp=_lastScrollTop!==null && top<_lastScrollTop-2 && _recentMessageUpwardIntent();
_lastScrollTop=top;
if(movedUp){ _nearBottomCount=0; _scrollPinned=false; } // #1731
else { _nearBottomCount=nearBottom?_nearBottomCount+1:0; _scrollPinned=_nearBottomCount>=2; } // #1360
if(movedUp){ _nearBottomCount=0; _scrollPinned=false; _messageUserUnpinned=true; } // #1731
else { _nearBottomCount=nearBottom?_nearBottomCount+1:0; _scrollPinned=_nearBottomCount>=2; if(_scrollPinned) _messageUserUnpinned=false; } // #1360
const btn=$('scrollToBottomBtn');
if(btn) btn.style.display=_scrollPinned?'none':'flex';
// Load older messages when scrolled near the top
@@ -1822,16 +1846,32 @@ document.addEventListener('DOMContentLoaded',function(){
tooltip.addEventListener('click',function(e){e.stopPropagation();});
});
function _setMessageScrollToBottom(){
const el=$('messages');
if(!el) return;
_programmaticScroll=true;
el.scrollTop=el.scrollHeight;
_lastScrollTop=el.scrollTop;
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
}
function _isMessagePaneNearBottom(threshold=250){
const el=$('messages');
if(!el) return false;
return el.scrollHeight-el.scrollTop-el.clientHeight<=threshold;
}
function _shouldFollowMessagesOnDomReplace(){
return !_messageUserUnpinned && (_scrollPinned || _isMessagePaneNearBottom(1200));
}
function scrollIfPinned(){
if(!_scrollPinned) return;
if(_recentNonMessageScrollIntent()) return;
const el=$('messages');
if(el){_programmaticScroll=true;el.scrollTop=el.scrollHeight;setTimeout(()=>{_programmaticScroll=false;},0);}
if(el){_programmaticScroll=true;el.scrollTop=el.scrollHeight;_lastScrollTop=el.scrollTop;requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });}
}
function scrollToBottom(){
_scrollPinned=true;
const el=$('messages');
if(el){_programmaticScroll=true;el.scrollTop=el.scrollHeight;setTimeout(()=>{_programmaticScroll=false;},0);}
_messageUserUnpinned=false;
_setMessageScrollToBottom();
const btn=$('scrollToBottomBtn');
if(btn) btn.style.display='none';
}
+5
View File
@@ -111,6 +111,11 @@ def test_dashboard_target_validation_allows_only_loopback_base_urls():
def test_status_tries_default_loopback_targets_until_dashboard_found(monkeypatch):
from api import dashboard_probe
# This test verifies the default auto-probe sequence. Other tests exercise
# .env/bootstrap behavior and may leave HERMES_WEBUI_HOST at 0.0.0.0 in the
# process env; make the default precondition explicit here.
monkeypatch.delenv("HERMES_WEBUI_HOST", raising=False)
attempts = []
def fake_probe(host, port, timeout=0.5, scheme="http"):
@@ -168,10 +168,13 @@ def test_i18n_locked_string_mentions_env_var_name_in_all_locales():
# ── Live HTTP smoke test (env var NOT set in pytest) ──────────────────────
def test_get_settings_returns_password_env_var_false_when_unset():
def test_get_settings_returns_password_env_var_false_when_unset(monkeypatch):
"""When HERMES_WEBUI_PASSWORD is not set in the test process,
GET /api/settings must include `password_env_var: False`."""
# The conftest server inherits this process's env; verify it's clean.
# Test the unset branch explicitly. Some suite neighbors intentionally set
# HERMES_WEBUI_PASSWORD while exercising the locked-password path.
monkeypatch.delenv('HERMES_WEBUI_PASSWORD', raising=False)
# The conftest server inherits a sanitized env; verify this process is clean.
assert not os.getenv('HERMES_WEBUI_PASSWORD', '').strip(), \
'this test requires HERMES_WEBUI_PASSWORD to be unset'
@@ -91,6 +91,46 @@ def test_upward_scroll_unpins_immediately_without_hysteresis():
)
def test_upward_motion_only_unpins_after_recent_user_intent():
"""Layout/programmatic scrollTop decreases must not masquerade as user scroll-up.
Long-session windowing can preserve/restore scroll positions while the live
stream is growing. If a plain scrollTop decrease always clears
``_scrollPinned``, the viewport can be visually at bottom while the state says
"not pinned", so streaming stops auto-following. Explicit wheel/touch upward
input must still unpin immediately; passive layout movement must not.
"""
assert "let _lastMessageUpwardIntentMs=" in UI_JS, (
"ui.js must track recent upward wheel/touch intent inside #messages so "
"programmatic/layout scroll changes do not permanently unpin streaming."
)
assert "function _recentMessageUpwardIntent()" in UI_JS, (
"ui.js must expose a recent upward transcript intent helper."
)
block = _scroll_listener_block()
moved_idx = block.index("const movedUp=")
moved_expr = block[moved_idx : block.find(";", moved_idx)]
assert "_recentMessageUpwardIntent()" in moved_expr, (
"movedUp must require recent wheel/touch upward intent, not only a "
"scrollTop decrease caused by DOM/layout changes."
)
def test_wheel_touch_upward_intent_is_recorded_inside_messages():
"""Wheel/touch gestures inside #messages must mark real upward user intent."""
fn_start = UI_JS.index("function _recordNonMessageScrollIntent")
fn_end = UI_JS.index("function _recentNonMessageScrollIntent", fn_start)
fn = UI_JS[fn_start:fn_end]
assert "_lastMessageUpwardIntentMs=performance.now()" in fn, (
"_recordNonMessageScrollIntent must timestamp real upward transcript "
"wheel/touch gestures before clearing _scrollPinned."
)
assert "e.deltaY<0" in fn and "e.type==='touchmove'" in fn, (
"Both wheel-up and touchmove gestures inside #messages should count as "
"user upward intent."
)
def test_downward_path_preserves_macos_momentum_hysteresis():
"""Downward / stationary motion must still go through the original
hysteresis re-pin path so the #1360 macOS trackpad momentum protection
@@ -0,0 +1,34 @@
from api.streaming import (
_fallback_title_from_exchange,
_strip_workspace_prefix,
_workspace_context_prefix,
)
def test_workspace_prefix_strips_only_versioned_sentinel():
assert _strip_workspace_prefix("[Workspace::v1: /tmp/project]\nHello") == "Hello"
assert _strip_workspace_prefix("[Workspace: /tmp/project]\nHello") == "[Workspace: /tmp/project]\nHello"
def test_workspace_prefix_escapes_paths_with_closing_brackets():
prefix = _workspace_context_prefix("/tmp/proj-[wip]/src")
assert prefix == "[Workspace::v1: /tmp/proj-[wip\\]/src]\n"
assert _strip_workspace_prefix(f"{prefix}Continue") == "Continue"
def test_legacy_workspace_prefix_only_strips_for_compatibility_callers():
legacy = "[Workspace: /tmp/project]\nContinue"
assert _strip_workspace_prefix(legacy) == legacy
assert _strip_workspace_prefix(legacy, include_legacy=True) == "Continue"
def test_user_typed_legacy_workspace_prefix_survives_fallback_title():
title = _fallback_title_from_exchange(
"[Workspace: /tmp/project]\nExplain this literal prefix",
"Sure",
)
assert title is not None
assert title.startswith("Workspace tmp/project")
+11
View File
@@ -93,6 +93,17 @@ def test_kanban_board_has_native_css_classes():
assert "overflow-x:auto" in COMPACT_STYLE
def test_kanban_main_view_scrolls_when_task_preview_is_tall():
"""The app shell keeps body overflow hidden, so the Kanban main view
must own vertical scrolling. Otherwise a selected task with a long body
can push the board below the viewport with no way to reach it.
"""
assert re.search(
r"main\.main\.showing-kanban\s*>\s*#mainKanban\s*\{[^}]*display:flex;[^}]*overflow-y:auto;",
COMPACT_STYLE,
), "Kanban main view must expose a vertical scrollbar when detail content is taller than the viewport"
def test_kanban_i18n_keys_exist_in_every_locale_block():
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
assert len(locale_blocks) >= 8
+32
View File
@@ -418,6 +418,38 @@ class TestDoneEventSmd:
"before renderMessages() in the 'done' handler source"
)
def test_done_handler_preserves_bottom_follow_on_final_render(self):
"""Final DOM replacement must keep auto-following users at the bottom.
The live stream path can be visually at bottom while _scrollPinned was
knocked false by history/windowing/layout preservation. On `done`, the
live DOM is replaced with persisted messages; if the handler blindly calls
renderMessages({preserveScroll:true}) while the pin flag is false, the
transcript can jump to the top. Capture bottom/follow intent before the
replacement and explicitly bottom only for those users.
"""
fn = self.get_fn()
assert fn, "'done' handler not found"
assert "shouldFollowOnDone" in fn, (
"'done' handler must capture whether the viewed transcript should "
"continue following before replacing the live DOM."
)
follow_idx = fn.index("shouldFollowOnDone")
render_idx = fn.index("renderMessages({preserveScroll:true})")
assert follow_idx < render_idx, (
"Follow intent must be captured before renderMessages() replaces the "
"live transcript DOM."
)
after_render = fn[render_idx:render_idx + 500]
assert "if(shouldFollowOnDone" in after_render and "scrollToBottom()" in after_render, (
"After final render, done handler must call scrollToBottom() when the "
"user was pinned/near-bottom before DOM replacement."
)
assert "_isMessagePaneNearBottom" in fn, (
"Done follow capture must include a near-bottom DOM check, not only "
"the possibly-stale _scrollPinned flag."
)
# ── 7. apperror event: smd parser ends cleanly ───────────────────────────────
+8 -1
View File
@@ -16,7 +16,14 @@ def test_workspace_display_prefix_helper_strips_leading_metadata_only():
assert end != -1, "user fenced block renderer not found after prefix stripper"
helper = src[start:end]
assert r"^\s*\[Workspace:[^\]]+\]\s*" in helper
# v1 sentinel regex must be present (matches `[Workspace::v1: <escaped path>]`).
assert r"^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*" in helper
# Legacy regex must ALSO be present as a fallback for transcripts saved
# before the v1 migration (per Opus advisor on stage-322 — without this,
# pre-upgrade sessions render the literal `[Workspace: /path]` prefix in
# user bubbles after upgrade). Mirrors the Python `include_legacy=True`
# branch in api/streaming.py:_strip_workspace_prefix().
assert r"\[Workspace:[^\]]+\]" in helper
assert ".trim()" in helper