Adds a 'storage' event listener for the hermes-webui-session localStorage key. Idle tabs auto-load the new active session and re-render the sidebar; busy tabs show a toast and do not interrupt the active turn.
Co-authored-by: Dennis Soong <dso2ng@gmail.com>
When a session's compression lineage spans multiple segments (linked via _lineage_root_id from api/agent_sessions.py), the sidebar previously rendered each segment as a separate top-level row. Adds _collapseSessionLineageForSidebar() that groups by lineage root and keeps only the most recently active tip per group, with a _lineage_collapsed_count marker for future UI affordances.
Co-authored-by: Dennis Soong <dso2ng@gmail.com>
- Drop btnCancel element and all JS show/hide call sites across
boot.js, messages.js, sessions.js, ui.js (superseded by single
primary action button)
- Remove .cancel-btn CSS rules including mobile media-query override
- Route updateSendBtn() title/aria-label through t() with English
fallbacks; add composer_send/queue/interrupt/steer/stop keys to all
7 locales (en, ru, es, de, zh, zh-Hant, ko)
- Branch disabled-state tooltip on reason: clarify lock, compression
running, or idle-empty, each with its own i18n key
- Update test_sprint10 / test_sprint36 to reflect single-button model:
assert btnSend present and id="btnCancel" absent; replace
test_hides_cancel_button with test_clears_composer_status
When _loadOlderMessages prepends older messages, the viewport snaps
to the bottom instead of staying where the user was.
Two bugs compounding:
1. Wrong scrollable container. Code used `$("msgInner")` for scrollHeight
and scrollTop, but #msgInner has no overflow-y — it is a flex column.
The actual scrollable container is #messages (`.messages{overflow-y:auto}`).
Setting msgInner.scrollTop was silently ignored.
2. renderMessages calls scrollToBottom at the end (ui.js:2552),
which unconditionally scrolls #messages to the bottom and sets
_scrollPinned=true. Since bug #1 made the scroll-restore a no-op,
the page landed at the bottom every time.
Fix:
- Changed scroll restore target from `$("msgInner")` to `$("messages")`.
- Reset _scrollPinned = false after restoring the user position,
so scrollToBottom does not re-fire on next tick.
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes#1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes#467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
Batch release v0.50.232 — 4 fixes.
## PRs included
| PR | Author | Fix |
|---|---|---|
| #1192 | @nesquena-hermes | Model chip fuzzy-match false positive (#1188) |
| #1193 | @nesquena-hermes | openai-codex not detected in model picker (#1189) |
| #1196 | @nesquena-hermes | Workspace files blank after second empty-session reload |
| #1197 | @bergeouss | Session timestamps wrong with server/client clock drift (#1144) |
All four PRs independently reviewed and approved by @nesquena.
## Integration fixes applied
**#1193:** Updated misleading comment — `OPENAI_API_KEY` does NOT authenticate the default Codex OAuth endpoint (that uses `chatgpt.com/backend-api/codex` and requires a separate OAuth flow). The comment now accurately states the known limitation. Also replaced a fragile 400-char source-scan test with an isolation-safe unit test. Note: OAuth-authenticated users already get detected via `hermes_cli.auth` — this fix only addresses the env-var fallback path.
## Test results
**2764 passed, 2 skipped** (macOS-only workspace tests). Browser QA: **21/21**. `/api/sessions` confirmed returning `server_time` and `server_tz` fields.
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts
- Add _copyText() helper: tries navigator.clipboard first, falls back to
document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests
* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge
- User message attachments with image extensions now render as <img> via
api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests
* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)
* fix: improve mobile touch responsiveness for session list items
iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch
Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
double-tap detection — works consistently on mouse and touch
* fix(#1106): iterate custom_providers[].models dict keys for dropdown population
- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests
* fix(#1105): allow custom_providers hostnames through SSRF check
- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)
* fix(tests): update hover padding assertions for #1110 touch fix (absorb)
* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)
* fix: filter right/middle-click from pointerup handler (absorb)
* docs: v0.50.221 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
* fix(ui): workspace panel collapse priority + visible project color dot
Two related sidebar UI bugs from project-ui-bugs.md:
1. Workspace panel header had no collapse priority. As the right panel
narrowed, all three header children (Workspace label, git badge,
icon buttons) compressed at the same rate because `.panel-header`
used `justify-content:space-between` with no flex-shrink ratios.
The icon buttons -- the actual primary controls -- could disappear
before the git badge (which is least-essential metadata).
Fix: declare `.rightpanel` as a `container-type:inline-size` container.
Replace `justify-content:space-between` with `gap:6px` plus
`margin-left:auto` on `.panel-actions`. Set flex-shrink:0 on
`.panel-actions` (icons never shrink), flex-shrink:2 on the label,
flex-shrink:3 on `.git-badge` (shrinks fastest), and
`min-width:0;text-overflow:ellipsis` for graceful intermediate
shrink. Add @container queries that crisply set `display:none` on
the git badge below 220px and on the label below 160px.
2. Project color dot was appended INSIDE the `.session-title` span,
which is `overflow:hidden;text-overflow:ellipsis`. Long titles
clipped the dot off entirely -- hiding the project marker exactly
when it was most needed. The timestamp was also `position:absolute`,
so the title's `flex:1` ran underneath it and there was nowhere
coherent to anchor the dot.
Fix: in sessions.js, append the dot to `titleRow` between title and
timestamp (a flex sibling, not inside the truncating title span).
In style.css, move `.session-time` from absolute positioning to
`margin-left:auto` in the flex row. Drop the
`margin-left:4px/vertical-align:middle` from
`.session-project-dot` (gap:6px on the row handles spacing).
Reduce `.session-item` padding-right at rest from 86px (which was
reserving space for the absolute timestamp) to 8px; expand to 40px
on hover/streaming/unread/menu-open/focus-within so the absolute
action button + attention indicator still have room.
Tests:
- tests/test_workspace_panel_session_list.py (14 new tests)
- tests/test_issue856_pinned_indicator_layout.py updated to reflect
the new flex-flow timestamp + reduced rest-padding
Full suite: 2433 passed, 47 skipped, 0 PR-related failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): remove duplicate margin-left:auto from .git-badge
With .panel-actions already carrying margin-left:auto, both .git-badge
and .panel-actions having auto margins split the free space equally,
centering the badge instead of keeping it adjacent to the label.
Remove margin-left:auto and margin-right:4px from .git-badge. The
panel-header gap:6px handles label→badge spacing; panel-actions
margin-left:auto owns the right-push. Layout: [label][badge][→][actions].
* fix(ui): mobile session-item padding 86px → 40px + git-badge margin fix
Two fixes from Opus independent review of #1089:
1. Mobile padding regression: .session-item mobile override had
padding:10px 86px 10px 12px — the 86px was reserving space for the
old position:absolute timestamp. Since the timestamp now lives in the
flex flow of .session-title-row (margin-left:auto), that 86px
reservation is wasted and pushes the timestamp ~76px from the right
edge, leaving dead space between it and the always-visible action
button. Fixed: 86px → 40px (matching desktop hover/attention rule,
only enough for the absolute action button at right:6px + 26px wide).
2. Duplicate margin-left:auto on .git-badge: the old rule from master
had margin-left:auto on .git-badge (for the old space-between layout).
With .panel-actions also having margin-left:auto, the two auto margins
split free space equally, floating the badge to the middle of the header
instead of keeping it flush against the label. Removed margin-left:auto
and margin-right:4px from .git-badge; gap:6px on .panel-header handles
label→badge spacing; .panel-actions margin-left:auto owns the right-push.
Updated tests:
- test_workspace_panel_session_list.py: assert 40px mobile padding
- test_issue856_pinned_indicator_layout.py: assert 40px mobile padding
Verified by Playwright visual QA:
- Desktop 250px: badge hidden, Workspace label visible, icons visible ✓
- Desktop 150px: badge hidden, label hidden, icons only ✓
- Project dots visible on long-title sessions (outside truncating title span) ✓
- Mobile: padding-right=40px, no layout overflow ✓
* docs: v0.50.220 release notes, test count 2481, roadmap
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(projects): opaque context menu + auto-sizing rename/create input
Two project chip UI bugs reported in project-ui-bugs.md:
1. Right-click context menu was transparent and the session list bled
through it. Root cause: _showProjectContextMenu set
background: var(--panel), but --panel is not defined anywhere in
style.css, so the menu fell back to transparent. Fix: use
var(--surface) -- the same opaque variable used by
.session-action-menu and other floating popovers.
2. The rename and new-project input field was hard-coded to 100px
regardless of the project name being edited (a 3-letter name got
the same field size as a 20-letter name). Fix: drop width:100px
from .project-create-input, replace with
min-width:40px / max-width:180px / width:auto. Add a
_resizeProjectInput() helper that measures the current value with
a hidden span and sets pixel width inside those bounds. Wired into
both _startProjectRename (called once on focus, again on every
input event) and _startProjectCreate (same pattern).
Tests: 9 new static-source tests in tests/test_project_chip_ui.py
that pin (a) var(--panel) is undefined in style.css so the fallback
trap doesn't return; (b) menu uses var(--surface); (c) the fixed
width:100px is gone and min/max bounds are present; (d) the
_resizeProjectInput helper is defined and called from both flows.
Full suite: 2419 passed, 47 skipped, 0 PR-related failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(projects): use getComputedStyle in _resizeProjectInput sizer span
Switch the hidden sizer span from hardcoded font-size:10px / font-family:inherit
to reading the live values from getComputedStyle(inp). This keeps the sizer
calibrated if the CSS rule ever changes, rather than silently drifting.
Also update test_resize_helper_uses_hidden_span to assert getComputedStyle
is used rather than the old literal font-size check.
Suggested by Opus independent review of #1086.
* docs: v0.50.219 release notes, test count 2467, roadmap update
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(css): add overflow-wrap:anywhere to chat bubbles — prevents long URL overflow (#1080)
* fix(projects): rename now works via dblclick timer guard + right-click color picker (#1078)
* fix(renderer): block-level constructs inside blockquotes now render
Fenced code blocks, headings, horizontal rules, and ordered lists inside
blockquotes now render correctly. Six related bugs documented in
blockquote-rendering-bugs.md were collapsed into one architectural fix
in renderMd().
Bugs fixed (all 6):
1. Fenced code blocks inside blockquotes -- > prefixes leaked into the
<pre> body and the blockquote got fragmented around the rendered
code, sometimes leaving raw <pre>/<div class="pre-header"> as
visible text.
2. Blank > continuation lines fragmented multi-paragraph blockquotes
into separate <blockquote> elements with literal > between them.
3. ## headings inside blockquotes rendered as literal "##" text.
4. Numbered lists inside blockquotes rendered as plain prose.
5. Complex blockquote (mixed headings + code + list + inline code)
collapsed into a monospace blob with raw markdown syntax leaking
everywhere.
6. Horizontal rules (---) inside blockquotes rendered as literal text.
Root cause:
The per-line passes for fenced code, headings, hr, ordered lists all ran
BEFORE the blockquote handler and could not match lines that started
with >, so by the time blockquote stripping ran those constructs had
already been mishandled.
Fix:
A new blockquote pre-pass at the top of renderMd():
- Walks lines fence-aware so > -prefixed lines inside non-blockquote
code fences (e.g. shell prompts in bash code blocks) are not
miscaptured as a blockquote.
- Groups consecutive > -prefixed lines, strips the > prefix, and
recursively calls renderMd() on the stripped content. The recursive
call handles all block-level constructs (fenced code, headings, hr,
ordered/unordered lists, nested blockquotes) using the same pipeline.
- Wraps the rendered HTML in <blockquote> and stashes it with a \x00Q
token. Restored at the very end of renderMd() so no later pass can
mangle the inner HTML.
The old _applyBlockquotes regex-replace is removed entirely along with
its limited inline branches for nested blockquotes and unordered lists.
Behaviour change:
Blockquotes now produce CommonMark-compliant <p> wrapping for text
content (was: bare text directly inside <blockquote>). The visual
output is the same in browsers but the HTML structure is now standard.
Tests:
- 14 new behavioural tests in tests/test_renderer_js_behaviour.py
drive the actual renderMd() via node and lock all 6 bug fixes.
- .local-review/test_blockquote_bugs.js -- node harness covering the
same scenarios, runnable manually for fast iteration.
- 2407/2408 tests pass (1 pre-existing macOS-only failure deselected).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(renderer): entity decode before blockquote pre-pass + CSS margin fix
- Move the >/</& entity-decode to run at the very top of
renderMd(), before the blockquote pre-pass. Previously decode() ran
at line 756 (after the pre-pass at line 697), so LLM output containing
>-encoded blockquotes was never matched by the pre-pass.
- Add .msg-body blockquote p{margin:0} and .preview-md blockquote p{margin:0}
so the new CommonMark-compliant <p> wrapping inside blockquotes doesn't
add extra vertical spacing. Prior shape (bare text) had no default p-margins.
- Add Node-driven tests: TestBlockquoteEntityEncodedInput covers > prefix
and >-encoded fenced code inside blockquotes.
- Add struct test: TestBlockquotePrePassOrdering::test_entity_decode_runs_before_blockquote_pre_pass
locks decode < _bq_stash ordering in ui.js.
Fixes found during Opus independent review of #1083.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: v0.50.218 release notes, test count 2458, roadmap update
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes#1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: apply pending #965 queue flyout patches on local master
Queue flyout implementation (PR #965 — pending merge) applied on top of
upstream v0.50.205. Features:
- Queue card slides up from behind composer (approval-card pattern)
- Lucide icons via li(), CSS class system, no inline SVG dumps
- Drag-to-reorder by _queued_at timestamp (survives re-renders)
- Inline contenteditable edit with focus guard and blur-commit
- Combine preserves first item files, merge immediate (no 200ms race)
- Files/model compact badges per item
- Hide/expand via header chevron + composer pill + titlebar chip
- All 3 expand paths sync correctly
- border-bottom CSS order fixed, fingerprint improved, _dragTs guards
CF CSP domains also applied (deployment-specific, not in upstream PR).
* fix(queue): harden merge closure, toggleQueue sid, and drain flash
- mergeBtn _doMerge now reads live queue (_getSessionQueue) instead of stale closure q
- toggleQueue reads activeSid from S.session at call time, not captured param
- updateQueueBadge defers chips.innerHTML='' by 360ms so slide-out transition completes before content clears
* style(queue): contain:paint on inner, pill fade-in animation
* feat(queue): pill outside composer, compact collapsed state matching card width
- Move #queuePill out of .composer-box to between .composer-flyout and .composer-box
- Pill styled as compact queue-card-inner (same border, radius:14px 14px 0 0, no border-bottom)
- Pill width matches card inner: max-width:calc(var(--msg-max)-40px), centered
- Pill stays visible until user re-expands or queue drains (updateQueueBadge no longer
hides pill when card is manually collapsed)
- Remove all queue-active/queue-pill-active composer modifications — composer untouched
- Fix: mergeBtn reads live queue not stale closure
- Fix: toggleQueue uses S.session.session_id at call time not captured param
- Fix: chips.innerHTML deferred 360ms on drain to avoid empty-card flash
* fix(queue): collapsed state persists + cross-session DOM isolation
- Add _queueCollapsed[sid] flag: set by hideBtn, cleared by pill expand / queue drain
- _renderQueueChips respects flag — no longer reopens card when new message queued while collapsed
- updateQueueBadge else-branch: DOM mutations now gated on sid===active session
- _syncQueueTitlebar only fires for active session in else-branch
- Fixes Opus/Codex-identified bugs: pill auto-reopen and cross-session DOM corruption
* fix(queue): proper pill wrapper matching queue-card structure
- Add .queue-pill-outer div wrapper (max-width:var(--msg-max); padding:0 20px)
identical to .queue-card outer — positions pill button at exact card-inner width
- .queue-pill button fills slot with width:100%
- Removes hardcoded 740px — width is derived correctly from the same CSS variables
the card uses, scales with --msg-max across all viewports
- JS toggles .show on pillOuter (parentElement), not on pill button directly
---------
Co-authored-by: Basit Mustafa <basit.mustafa@gmail.com>
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes#1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes#995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* i18n: add complete Traditional Chinese (zh-Hant) translations
- Add 300+ zh-Hant translation entries covering all UI sections:
onboarding, settings/Control Center, session actions, cron jobs,
providers panel, workspace management, skills, profiles, todos, BTW
- Fix existing zh-Hant translations: remove mixed Simplified Chinese
characters, fix typos (e.g. 皮膚→佈景, 待踩→待辦, 新存對話→新對話)
- Update zh locale: fix 需要审批→需要审核 (Simplified Chinese correction)
- Add data-i18n attributes to Control Center HTML (index.html) for
heading, subtitle, tab names, dropdown, and section titles
- Migrate session action menu (sessions.js) from hardcoded English to
t() function calls for full i18n support
* fix: translate remaining English entries to Traditional Chinese in zh-Hant locale
- settings_heading_title: 'Control Center' → '控制中心'
- settings_dropdown_providers: 'Providers' → '供應商'
- providers_section_title: 'Providers' → '供應商'
- providers_tab_title: 'Providers' → '供應商'
* fix: add missing locale keys to zh/ru/es/de + restore zh approval_heading
- zh (Simplified): reverted approval_heading to 需要审批 (matches master)
PR had changed it to 需要审核 which broke the representative-translation test
- zh/ru/es/de: added 39 new session management + settings keys as English
fallback strings (session_archive, session_pin, settings_dropdown_*, etc.)
These keys were added to English in this PR but missing from other locales
- es: added cmd_status (English fallback) to fix coverage gap
- Fixes all locale coverage test failures
---------
Co-authored-by: 陳俊宇 <chenjunyu@chenjunyudeMacBook-Air-7.local>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
- Backend: save session JSON with metadata fields before messages array
so load_metadata_only() reads only ~1KB without parsing the full session
- Backend: add GET /api/session?messages=0 for metadata-only responses
(~1KB vs ~400KB), enabling instant sidebar switching
- Backend: add POST /api/admin/reload to hot-reload models without restart
- Backend: gzip compress JSON API responses (>1KB) for 70-80% bandwidth reduction
- Frontend: show Loading indicator immediately on session switch, replacing
old DOM before API call to prevent stale content flash
- Frontend: clear S.messages before API call so _ensureMessagesLoaded
always fetches fresh data for the target session
- Frontend: wrap both Phase 1 (messages=0) and Phase 2 (_ensureMessagesLoaded)
in try/catch to prevent permanently stuck loading state on network/server errors
refactor(ui): three-column layout with left rail + main-view migration (#899)
Unifies the shell into a three-column layout (rail + sidebar + main) matching the
hermes-desktop reference, and migrates every per-item detail/edit surface into a
shared main-view canvas with consistent headers, empty states, and action buttons.
Changes:
- New desktop-only left rail (48px) with 8 nav tabs (chat/tasks/skills/memory/workspaces/profiles/todos/settings)
- Persistent app titlebar (replaces per-chat topbar), active conversation title shown
- All panel detail/create/edit views migrated to #mainSkills, #mainTasks, #mainSettings, #mainWorkspaces, #mainProfiles, #mainMemory
- Settings moved out of modal into main-view page; ESC closes it
- YAML frontmatter rendered in collapsible <details> block in skill detail
- Toasts repositioned from bottom-center to top-right with theme-aware success/error/warning/info variants
- Composer workspace chip split into two-button group: files-icon toggles file panel, label opens workspace picker
- .settings-menu → .side-menu / .side-menu-item (generalised, shared by memory and settings panels)
- i18n: ~25 new keys across en/ru/es/de/zh/zh-Hant for all new form labels, placeholders, and empty states
- Mobile: hamburger in titlebar, slide-in sidebar; box-shadow removed from sidebar
- New regression test: tests/test_settings_navigation_and_detail_refresh.py (9 tests)
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
* fix(settings): show live models in default model picker and apply to new chats (#872)
Two related bugs:
1. Settings > Preferences > Default Model dropdown only showed static models
from /api/models — live-fetched models (e.g. @nous:anthropic/claude-opus-4.7)
were missing. Now calls _fetchLiveModels() on the settings picker too.
2. New chats ignored the saved default model preference — they always used the
chat-header dropdown value (which reflects the previous session's model).
Now newSession() uses the saved default_model and syncs the dropdown.
Extracted _addLiveModelsToSelect() from _fetchLiveModels() so cached live models
can be applied to any <select> element (chat-header or settings picker).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(tests): update live-model prefix tests for _addLiveModelsToSelect extraction
The tests searched for og.dataset.provider, _isPortalFetch, and openrouter
exclusion patterns inside _fetchLiveModels(). These were extracted into
_addLiveModelsToSelect() as part of the #872 fix. Updated regex targets to
check _addLiveModelsToSelect first, falling back to _fetchLiveModels.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: add multi-tab note on window._defaultModel
Clarifies that window._defaultModel is per-page-load and not synced
across browser tabs, following maintainer feedback on #889.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: CHANGELOG for v0.50.170
* chore: trigger PR refresh after rebase
---------
Co-authored-by: fr33m1nd <bergeouss@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(sessions): surface gateway SSE failures and add polling fallback
- add a JSON probe mode for the gateway SSE endpoint
- detect watcher-unavailable 503s from the browser
- fall back to periodic session refresh with a toast
- add probe payload tests and endpoint coverage
Fixes#635
* fix(sessions): surface gateway SSE failures and add polling fallback (#826)
Absorbed from PR #826 by @cloudyun888 (fixes#635).
When the gateway watcher thread is not running, the browser now shows a
toast notification and falls back to 30-second periodic polling for session
sync. Previously the SSE failure was completely silent with no user feedback.
Changes from original PR:
- Deleted misplaced test_gateway_sse_probe_unit.py (was at repo root, not
discovered by `pytest tests/`); unit tests moved into tests/test_gateway_sync.py
- _gateway_sse_probe_payload now checks watcher._thread.is_alive() rather
than just watcher is not None — a watcher instance with a dead poll thread
now correctly reports unavailable and activates the polling fallback
- probeGatewaySSEStatus catch(e) now starts the polling fallback on network
error rather than silently swallowing the failure
- Added 5 unit tests covering all watcher-alive/dead/missing/disabled branches
Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@users.noreply.github.com>
* cleanup(gateway): public is_alive() + dedup probe/live watcher-alive check + changelog
Three small cleanups on top of @cloudyun888's PR #826 absorption:
1. Add GatewayWatcher.is_alive() public accessor so routes.py doesn't
reach into the private _thread attribute. The existing private-
attribute check stays as a defensive fallback for any older in-
memory instance or test double that doesn't implement the full API.
2. Dedupe the watcher_alive computation in _handle_gateway_sse_stream:
the live-SSE path now calls _gateway_sse_probe_payload(...) and reads
its watcher_running field instead of re-deriving the same logic
inline. Keeps probe and SSE in sync automatically.
3. CHANGELOG trailer was (#826, fixes#635, @cloudyun888) — this PR is
#828, so updated to (#828, absorbs PR #826 by @cloudyun888, fixes
#635) matching the repo convention for absorbed PRs (see #805).
Added two regression tests:
- test_gateway_watcher_is_alive_public_method — covers the three
lifecycle states (before start, while running, after stop).
- test_probe_payload_prefers_public_is_alive — asserts the probe
uses watcher.is_alive() rather than poking _thread when the
public method exists.
Full suite: 1735 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the multi-client profile isolation bug (#798).
- get_hermes_home_for_profile(): pure path resolver, validates name against
_PROFILE_ID_RE (rejects path traversal), never mutates os.environ or globals
- new_session() accepts explicit profile= param from POST body (S.activeProfile),
short-circuits the process-level _active_profile global
- streaming handler resolves HERMES_HOME from s.profile instead of the global
- sessions.js sends profile: S.activeProfile in every new-session POST
10 tests in tests/test_issue798.py including concurrency and traversal coverage.
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
Queued follow-up messages now survive page refresh. Persisted atomically in queueSessionMessage/shiftQueuedSessionMessage. On reload: if agent still active, queue is silently hydrated (done handler drains it); if idle, first entry is restored as a composer draft with a toast. Stale entries discarded.
Fixes#660
Adds compact/detailed toggle for the session list sidebar. Compact is the default (no behavior change for existing users). Detailed mode shows message count and model; profile names only appear when mixing sessions across profiles.
Fixes#673
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Squash-merges feature from PR #588 by @vcavichini. Dynamic <base href> injection + api() helper slash-stripping enables deploying hermes-webui behind a reverse proxy at any subpath without configuration. Also fixes pre-existing bug: api/upload was using location.origin instead of location.href (closes#596). Co-authored-by: vcavichini <vcavichini@users.noreply.github.com>
Squash-merges PR #584 by @aronprins.
Drops the meta row (message count, model slug, source-tag badge) from every sidebar session item. Each session now renders as a single title line — visible session count roughly doubles at typical viewport height.
Changes merged verbatim from contributor branch, plus maintainer additions:
- CHANGELOG entry for v0.50.64
- Version badge bump to v0.50.64
- New test: test_relative_time_today_bucket (closes minor coverage gap from code review)
Co-authored-by: aronprins <aronprins@users.noreply.github.com>
- sessions.js _formatSourceTag(): return null for unrecognised tags
instead of raw string — prevents legacy 'N/A' values from surfacing
- sessions.js metaBits push: guarded with _stLabel null check so only
known platform labels appear in the session metadata line
- sessions.js [SYSTEM:] title fallback: drop raw s.source_tag middle
term, fall back directly to 'Gateway' for unknown sources
7 new tests in test_issue429.py.
1 updated test in test_sprint40_ui_polish.py (new guarded push pattern).
Closes#429
* fix: correct tool call card rendering on session load
Two bugs caused duplicate/incorrect tool call cards when loading
sessions (especially after context compaction):
1. loadSession() sanitized messages (B9 filter) but did NOT update
the session-level tool_calls array's assistant_msg_idx references.
Since compact() returns only sanitized messages and recomputes
tool_calls with indices into the compacted array, the original
assistant_msg_idx values became stale/misaligned.
2. loadSession() then assigned the broken session-level tool_calls
directly to S.toolCalls. This prevented renderMessages()'s fallback
path (which derives tool_calls from per-message tool_calls using
correct sanitized-array indices) from ever running.
Fix:
- Keep full sanitization loop with index remapping for session-level
tool_calls (in case they're needed by other code paths).
- Instead of assigning broken session-level tool_calls to S.toolCalls,
set S.toolCalls=[] so renderMessages() uses the fallback derivation
from per-message tool_calls, which already have correct indices.
* test: add 8 regression tests for issue #401 tool call index remapping
* docs: v0.50.29 release — version badge and CHANGELOG
---------
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix: preserve live session output across chat switches
(cherry picked from commit 401e3b643d25e8dad8c06883b478b3c3073f07a5)
* fix: preserve todo state after session reload
(cherry picked from commit 7ee093ba19978af23b79148df2f2347e2f1e5bde)
* fix: preserve live assistant anchor across rerenders
* fix: stream live reasoning and tool progress
* fix: recover inflight session state after reload
* fix: add loadInflightState stub + CHANGELOG v0.50.21
- static/ui.js: add loadInflightState() function (currently returns null —
the typeof guard in sessions.js means reload recovery works via the
else-path attachLiveStream call; this stub satisfies the guard cleanly
and documents the extension point for future localStorage-backed state)
- CHANGELOG.md: v0.50.21 entry; 960 tests (up from 949)
---------
Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>