Commit Graph

416 Commits

Author SHA1 Message Date
Andy 9fabd12e41 fix: preserve clarify drafts on timeout 2026-04-29 04:32:40 +00:00
bergeouss 4dbc9ac3e1 fix(i18n): add cron_status_running to de locale and fix es fallback 2026-04-29 04:32:00 +00:00
bergeouss d734efa8af fix: stop cron watch when clearing cron panel detail view
_stopCronWatch() was only called when switching between cron job details
but not when the panel was cleared entirely (_clearCronDetail). This could
leave orphaned polling intervals if the user navigated away or the panel
was dismissed while a job was running.
2026-04-29 04:32:00 +00:00
bergeouss 98ed2d804b feat: cron run status tracking and watch mode (#526)
Backend:
- Track running cron jobs in thread-safe dict (job_id → start_time)
- Wrapper _run_cron_tracked() marks done on completion
- New GET /api/crons/status?job_id=... returns {running, elapsed}
- New GET /api/crons/status returns all running jobs

Frontend:
- After 'Run Now', enters watch mode with 3s polling
- Shows running indicator (spinner + elapsed timer) in detail card
- Auto-detects running jobs when opening detail view
- Stops watch and refreshes output on job completion
- Cleanup on detail view switch

Note: True SSE streaming is not possible because the hermes-agent
scheduler writes output files only on completion. This polling
approach provides real-time status feedback within that constraint.
2026-04-29 04:32:00 +00:00
bergeouss 8c24b24dcd feat: upload and extract zip/tar archives into workspace (#525)
- Add extract_archive() with zip-slip and tar-slip protection
- New /api/upload/extract endpoint for archive uploads
- Auto-detect archive files (.zip, .tar.gz, .tgz, .bz2, .xz)
- Archives extracted into named subfolder (avoids overwrites)
- Workspace file tree auto-refreshes after extraction
- Archive extensions added to file picker accept list
- i18n: archive_extracted key in all 7 locales

Security: path traversal blocked via resolve() prefix check,
matching existing safe_resolve_ws() sandbox pattern.
2026-04-29 04:31:59 +00:00
bergeouss d08d96f864 fix: deduplicate clone name + explicit enabled:false for duplicates
- Name dedup: 'Job (copy)', 'Job (copy 2)', 'Job (copy 3)' etc.
- Duplicates explicitly pass enabled:false to backend
- Normal cron create is unaffected (no enabled field sent)

Addresses reviewer feedback on #1225 (points 1 + 4).
2026-04-29 04:31:59 +00:00
bergeouss 8c63324ff7 feat: duplicate cron job with form pre-fill (#528)
- Add duplicate button in cron detail header
- Pre-fills create form with original job settings
- New job created as paused copy with '(copy)' suffix
- i18n keys in all 7 locales
2026-04-29 04:31:59 +00:00
bergeouss 9c57d36156 fix: update expanded dirs cache on double-click directory rename
The inline rename via double-click (nameEl.ondblclick) was not updating
the _expandedDirs and _dirCache when renaming a directory, unlike the
context-menu rename path (_inlineRenameFileItem) which already had this
logic. This could cause the tree view to show stale expand state after
a directory was renamed via double-click.
2026-04-29 04:31:58 +00:00
bergeouss 38df294af9 feat(#1104): workspace directory CRUD — delete, rename, context menu
The file tree already supported file rename (double-click), file delete
(button), and create file/folder.  This adds the missing directory
operations:

Backend:
- _handle_file_delete now supports directories when recursive=true
  (uses shutil.rmtree instead of blocking with an error)

Frontend:
- Right-click context menu on all file/directory items with Rename
  and Delete options (follows the project context menu pattern)
- Directory delete button (x) with confirmation dialog
- _inlineRenameFileItem() for renaming dirs via context menu prompt
- Expanded-dir cache is updated on rename/delete to stay consistent
- Context menu auto-positions within viewport bounds

i18n: delete_dir_confirm, rename_title, rename_prompt in all 7 locales

Closes #1104
2026-04-29 04:31:58 +00:00
starship-s 03b7714f65 docs: note Lucide source for composer icons 2026-04-29 04:31:56 +00:00
starship-s 62650e6a0d fix: add missing commas after approval_skip_all_title in all locales 2026-04-29 04:31:56 +00:00
starship-s 9d5480565f fix: remove deprecated btnCancel; localise composer tooltips with disabled reason branching
- 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
2026-04-29 04:31:55 +00:00
starship-s b57134bf2b ui: reflect explicit busy slash command in send button 2026-04-29 04:31:55 +00:00
starship-s be291498cf ui: swap composer action icons to Lucide (ISC-licensed)
- queue: list-end (append to queue)
- interrupt: skip-forward (jump ahead)
- steer: compass (course correction)
2026-04-29 04:31:54 +00:00
starship-s 8eb3d8bdbc chore: strip remaining btnCancel inline-flex references (superseded by single-button model) 2026-04-29 04:31:54 +00:00
starship-s 96182e5f51 fix: keep busy-input send available on mobile 2026-04-29 04:31:54 +00:00
fxd-jason 26f51b7190 fix: address review feedback — restore V3 as legacy, fix zai base_url
- Restore deepseek-chat-v3-0324 and deepseek-reasoner with '(legacy)' labels;
  these are deprecated 2026-07-24 but still live until then
- Fix zai (Z.AI/GLM) default_base_url: use /api/paas/v4 instead of /api/coding/paas/v4;
  the coding plan path is for the glmcode custom provider, not the general API
- Update test assertions to match
2026-04-29 04:31:16 +00:00
fxd-jason 25958139da feat: show model names in provider cards + scan custom_providers
Provider card improvements:
- Show model name tags when a provider card is expanded (panels.js)
- Add .provider-card-model-tag styling (style.css)

Custom providers in providers panel:
- Scan config.yaml custom_providers (e.g. glmcode, timicc) and list
  them as providers with their configured models (api/providers.py)
- Detect API key status from env var references (${ENV_VAR})
2026-04-29 04:31:15 +00:00
fxd-jason 568a913615 chore: remove deprecated DeepSeek V3/R1 models, keep only V4
- Remove deepseek-chat-v3-0324 (DeepSeek V3) and deepseek-reasoner (R1)
  from _MODEL_LIST, _PROVIDER_MODELS, static/index.html, and static/ui.js
- Keep only deepseek-v4-flash and deepseek-v4-pro
- These old model IDs are deprecated since 2026-07-24
2026-04-29 04:31:15 +00:00
fxd-jason 9df01c6167 feat: add DeepSeek V4 Flash and V4 Pro models
Add deepseek-v4-flash and deepseek-v4-pro model entries to:
- api/config.py (_MODEL_LIST and _PROVIDER_MODELS)
- static/index.html (model dropdown)
- static/ui.js (static label map)

These are the latest DeepSeek models with 1M context window,
replacing the legacy deepseek-chat/deepseek-reasoner (deprecated 2026-07-24).
2026-04-29 04:31:14 +00:00
Frank Song f384368ee2 fix(sessions): preserve unread dots after compression 2026-04-29 04:31:14 +00:00
Frank Song 248cfd1248 Track cache-rendered streaming sessions for unread dots 2026-04-29 04:31:14 +00:00
Frank Song 6b04ae0254 Fix unread markers for local inflight completions 2026-04-29 04:31:13 +00:00
Frank Song b488a0d1b0 Fix unread markers for background completions 2026-04-29 04:31:13 +00:00
Frank Song 5d16ff7522 Fix background completion unread markers 2026-04-29 04:31:13 +00:00
bergeouss a8101d98f7 fix(models): deduplicate model IDs across provider groups (#1228)
When multiple providers expose the same bare model ID (e.g. two custom
providers both listing gpt-5.4), the model picker cannot distinguish
them — both rows appear active and clicking the other provider's copy
is a no-op.

Fix:
- Add _deduplicate_model_ids() post-process in api/config.py that
  detects duplicate bare model IDs across groups and prefixes
  collisions with @provider_id: so each entry is globally unique
- Update norm() regex in static/ui.js to strip @provider: prefixes
  for fuzzy matching, so existing sessions with bare model IDs still
  restore correctly
- First occurrence stays bare for backward compatibility with sessions
  that already store the bare model name
- Update test_model_resolver to be dedup-aware

Closes #1228
2026-04-29 04:31:11 +00:00
bergeouss ae2ed1a4e7 Fix #1214: refresh workspace on profile switch when session is empty
Add loadDir('.') call in switchToProfile() Case B so the workspace file
tree panel reflects the new profile's workspace instead of showing stale
files from the previous profile.

Fix #1212: detect OAuth providers not in hardcoded set

Expand _OAUTH_PROVIDERS with copilot-acp and qwen-oauth.
Add fallback in get_providers() that checks hermes auth live status
for providers that have no API key and are not in the hardcoded set
(e.g. Anthropic connected via OAuth), so the Providers tab shows
them as configured.
2026-04-29 04:31:11 +00:00
fxd-jason f7f8fc6496 fix: _loadOlderMessages scrolls to bottom instead of preserving position
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.
2026-04-29 04:30:55 +00:00
nesquena-hermes 24b1e6f3fc fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
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
2026-04-27 22:56:12 -07:00
nesquena-hermes 7189416969 fix: batch v0.50.234-235 — XSS hardening, workspace validation, profile switch fixes (#1206)
fix: batch v0.50.234-235 — XSS hardening, workspace validation, profile switch fixes

v0.50.235 (#1203 — profile switch workspace/model/chip, 3 bugs + flaky test):
- switch_profile now reads target profile's workspace directly (thread-local bypass)
- invalidate_models_cache() after profile switch (model dropdown staleness)
- syncTopbar() updates chip before early-return (no-session path)

v0.50.234 (#1201/#1205 — XSS hardening + workspace security):
- renderMd() full HTML attribute sanitizer replacing tag-name-only allowlist
- Delegated image lightbox (removes all inline onclick)
- macOS /etc → /private/etc symlink bypass fixed
- /System /Library added to blocked workspace roots
- Legacy /api/chat workspace trust gap closed

Both PRs independently reviewed. 2787/2787 tests. QA harness 20/20 + 11/11 API checks.

Co-authored-by: Brendan Schmid <bschmidy10@Wilson.bschmidy10>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-27 21:39:30 -07:00
nesquena-hermes 3780df9428 fix: batch v0.50.232 — fuzzy match, codex detection, workspace reload, timestamp sync (#1198)
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.
2026-04-27 18:40:13 -07:00
nesquena-hermes e61a405add fix: batch v0.50.231 — macOS symlink bypass, workspace panel, fenced code leak (#1194)
Batch release v0.50.231 — 3 fixes.

## PRs included

| PR | Author | Fix |
|---|---|---|
| #1186 | @nesquena (Claude Code) | macOS `/etc` symlink bypass in workspace blocked-roots |
| #1187 | @nesquena-hermes | Workspace panel stuck closed after empty-session reload |
| #1190 | @bergeouss | Fenced code content leaking into markdown passes (#1154) |

All three PRs were independently reviewed and approved by @nesquena.

## Test results

**2729 passed, 2 skipped** (2 macOS-only tests correctly skipped on Linux). Browser QA: **21/21**.

## Key fix notes

**#1186:** `_workspace_blocked_roots()` now returns both literal and `Path.resolve()` forms of each blocked root. macOS symlinks (`/etc → /private/etc`) previously let a resolved candidate slip past the literal check. New `_is_blocked_system_path()` helper with `/var/folders` and `/var/tmp` carve-outs for pytest temp dirs.

**#1187:** Regression from #1182 — `syncWorkspacePanelState()` force-closed on any no-session state. Now only closes in `'preview'` mode. Both boot paths restore localStorage panel pref before sync.

**#1190:** Fenced code blocks are now stashed as `\x00P<n>\x00` tokens through ALL markdown passes (list/heading/table regexes), restored at the very end. Previously, diff hunks and markdown headings inside code blocks triggered those regexes, injecting `<ul>/<li>/<h>` tags that broke `</pre>` closure.
2026-04-27 17:43:36 -07:00
nesquena-hermes a091be6a8e fix: batch v0.50.229 — session perf, ephemeral sessions, iOS zoom (#1183)
Merged as v0.50.229. 2678 tests passing. Browser QA 21/21.

All three PRs were independently reviewed and approved by @nesquena with reviewer commits pulled in:
- #1181 (#1158): `d974388` (stale-response race in _loadOlderMessages)
- #1182: `7e20006` (full-scan fallback path consistency)
- #1180: `a5ad154` (regression test for iOS zoom threshold)

Thanks @jasonjcwu (#1158)!
2026-04-27 16:27:03 -07:00
nesquena-hermes ef26d19549 fix: batch v0.50.228 — renderer, model race, tool card, empty session, .env (#1179)
Merged as v0.50.228. 2644 tests passing. Browser QA 21/21 (desktop 1440×900 + mobile iPhone 14). All 5 fix invariants verified live in browser.

**Fix verifications:**
- #1172 (`renderMd` pre-stash): `rawPreStash` present in function, `<pre>` blocks pass through without content rewrite 
- #1174 (model race guard): `syncTopbar()` contains `liveStillPending` guard 
- #1175 (tool card): `.tool-card-result pre` max-height=360px, `.tool-card.open .tool-card-detail` overflow=auto, cap=600px   
- #1176 (empty session guard): double-click New Conversation on empty session → stays on same session, composer focused 
- #1178 (`.env` atomic write): `tempfile.mkstemp + os.replace` in `providers.py`, 9/9 env tests pass 

Thanks @bsgdigital (#1150) and @bergeouss (#1178)!
2026-04-27 15:28:19 -07:00
nesquena-hermes 8b8ff3328a fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.

Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)

Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 13:34:59 -07:00
nesquena-hermes dca8624454 fix(ui): restore rail-era app titlebar state (v0.50.226) (#1163)
Merged as v0.50.226.

Integration branch absorbed @aronprins's original PR #1141 with one reviewer fix from @nesquena (`1d11646`: queue hide tooltip updated to reference the queue pill, not the removed titlebar badge).

**Full gate results:**
- 2595 tests passing 
- Browser QA 21/21 (desktop 1440×900 + mobile iPhone 14) 
- Independent review: APPROVED by @nesquena 

Thank you @aronprins for the clean PR — the titlebar is properly restored.
2026-04-27 11:43:32 -07:00
nesquena-hermes 5192ca5de5 v0.50.225: cron attention, image lightbox, pytest isolation (#1137)
* feat: attention state for broken cron jobs + Korean i18n (#1133, @franksong2702)

* fix: pytest state isolation for direct session saves (#1136, @franksong2702)

* fix(#1095): image thumbnails in composer + lightbox in chat (#1135)

* fix(css): restore cron attention + detail-alert rules overwritten by style.css merge (absorb)

* docs: v0.50.225 release notes and version bump

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 21:04:38 -07:00
nesquena-hermes fc0152b2fc v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy (#1127)
* fix(#604): model picker shows all configured providers

Two fixes to ensure the model picker surface every provider a user has
configured:

1. Added env var detection for XAI_API_KEY (→ x-ai) and MISTRAL_API_KEY
   (→ mistralai). Previously these providers were only detectable via
   hermes auth or credential pool, not via environment variables.

2. Added config.yaml providers section scanning. Users who configure
   providers in config.yaml (e.g. providers.anthropic.api_key) without
   setting the corresponding env var will now see those providers in the
   model picker. Only providers with known model catalogs are added.

- Added 12 regression tests

* fix(#1112): allow Google Fonts in CSP style-src and font-src

Mermaid themes inject @import for fonts.googleapis.com at render time.
CSP style-src blocked these requests, causing console violations.

- Add https://fonts.googleapis.com to style-src (CSS stylesheets)
- Add https://fonts.gstatic.com to font-src (WOFF2/WOFF font files)
- Add 3 regression tests + verify existing CSP tests still pass

* fix(#1118): retry api() calls on network errors after long idle

After a long idle period, the browser's TCP keep-alive connection to the
server can become stale. The next fetch() throws a TypeError (network
failure), causing 'Failed to load session' instead of transparently
reconnecting.

- Added retry loop in api() (workspace.js): up to 3 attempts
- Only retries on TypeError (network failures), NOT on HTTP errors (4xx/5xx)
- 401 redirects still fire immediately
- Added 6 regression tests

* feat(#1116): composer placeholder reflects active profile name

When a named profile is active (not 'default'), the composer placeholder
and title bar show the profile name (capitalised) instead of the global
bot_name. Falls back to bot_name/'Hermes' for the default profile.

- boot.js: applyBotName() checks S.activeProfile before _botName
- panels.js: switchToProfile() calls applyBotName() after switch
- Added 5 regression tests

* feat(#1097): drag and drop workspace files into chat composer

Files and folders in the workspace file tree are now draggable.
Dropping them into the composer inserts @path reference at cursor
position. OS file drag-and-drop (attach files) still works.

- ui.js: _renderTreeItems sets draggable + dragstart with ws-path
- panels.js: drop handler checks for application/ws-path first,
  inserts @path with smart spacing and cursor positioning
- Added 9 regression tests

* fix(#1096): copy buttons work — add clipboard-write Permissions-Policy

Copy buttons on messages and code blocks were silently failing because
the Permissions-Policy header did not include clipboard-write=(self).
Firefox blocks navigator.clipboard.writeText() without explicit permission.

- api/helpers.py: add clipboard-write=(self) to Permissions-Policy
- ui.js: _copyText now catches clipboard API errors and falls back
  to execCommand('copy'). _fallbackCopy extracted as separate function
  with proper focus() call and visible-but-hidden positioning (not -9999px)
- Added 8 regression tests

* chore: CHANGELOG for v0.50.223

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 15:29:02 -07:00
nesquena-hermes 4528c6c848 v0.50.222: Korean locale, provider fixes, reasoning chip boot, Prism SRI (#1119)
* feat: add Korean locale support (#1093, @jundev0001) — 615 keys, copy_failed added

* fix(#1094): provider deletion + false positive API key + threading deadlock (#1102, @bergeouss)

* fix(#1103): show reasoning chip on page load not only after session load (#1114, @bergeouss)

* fix(#1100): remove Prism CSS SRI integrity to fix intermittent blocking (#1115, @bergeouss)

* fix(tests): update copy_failed locale count for 7 locales (Korean added)

* fix: drop unused _cfg_cache import; update locale count comment

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 14:24:20 -07:00
nesquena-hermes 27b17a8fc8 v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
* 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>
2026-04-26 10:36:59 -07:00
nesquena-hermes d67036db24 v0.50.220: workspace panel collapse + project color dot fix (#1090)
* 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>
2026-04-26 00:19:05 -07:00
nesquena-hermes d625bac6d4 v0.50.219: project chip context menu + input auto-sizing (#1087)
* 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>
2026-04-25 23:28:29 -07:00
nesquena-hermes 498b51bfc6 v0.50.218: chat bubble overflow, project color picker, blockquote renderer (#1085)
* 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 &gt;/&lt;/&amp; 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
  &gt;-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 &gt; prefix
  and &gt;-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>
2026-04-25 23:08:59 -07:00
nesquena-hermes 62adc0c00d v0.50.217: /queue /interrupt /steer send normally when agent is idle (#1077)
* fix(commands): /queue /interrupt /steer send normally when agent is idle

When the agent is not running, these three commands now fall through to a
direct send() call (setting the input value and invoking send()) instead
of showing an error toast.  This matches CLI behaviour — the commands are
mode-sensitive: they operate as queue/interrupt/steer when busy, and as
normal sends when idle.

Before:
  /queue hello   → "No active task — just send normally" (toast, nothing sent)
  /steer hello   → "No active task to stop." (misleading + nothing sent)
  /interrupt hi  → "No active task to stop." (nothing sent)

After:
  /queue hello   → message sent immediately (same as typing and pressing Enter)
  /steer hello   → message sent immediately
  /interrupt hi  → message sent immediately

Note: /stop when idle still shows "No active task" — that one is correct
since stopping nothing is always an error.

15 new tests in test_cmd_idle_fallback.py covering the idle path for all
three commands and verifying the active-session paths are unchanged.

* test(commands): update stale test doc — /queue idle now sends, not rejects

test_cmd_queue_requires_busy was written before the idle-send fallback
existed. Its docstring said "/queue while not busy is a usage error"
and the assertion message said "reject if idle" — both accurate for the
old toast-and-return behaviour but wrong after this PR.

The test assertion itself (`"if(!S.busy)" in body`) still passes because
the idle guard still exists; it just routes to send() instead of a toast.
Updating the name and copy to accurately describe what the code now does,
so the test reads as documentation rather than as a contradiction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: v0.50.217 release notes and version bump

---------

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>
2026-04-25 21:35:51 -07:00
nesquena-hermes 58ad315dca v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly

MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.

The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.

* test(workspace): lock in MIME_MAP entry for .html/.htm

PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).

Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(composer): raise .approval-card.visible z-index above .queue-card

.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.

Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.

* test(composer): pin approval-card z-index > queue-card invariant

PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.

Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.

Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: intercept /steer /interrupt /queue before busy-mode routing in send()

Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."

Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.

Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
  intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
  _bc.fn(_pc.args) and returns, not queues

* test(busy-intercept): pin sync input-clear before await in slash intercept

PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."

Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: update steer i18n and settings copy — steer no longer interrupts

With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.

Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
  cmd_steer:                  "falls back to interrupt" removed
  settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
  cmd_steer_fallback:         "interrupted" → "queued for next turn"
  busy_steer_fallback:        "interrupted instead" → "queued for next turn"
  settings_desc_busy_input_mode: "currently falls back to interrupt" removed

Also:
  static/index.html: inline fallback text updated to match
  static/commands.js: internal comment clarified (fallback = queue+cancel,
                      not "interrupt mode" which implies the primary action)

* fix(renderer): group consecutive blockquote lines into single element

Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
  1. `.+` required at least one character — bare `>` lines (blank
     continuation lines) did not match and passed through as literal `>`
  2. Each matching line became its own `<blockquote>` element — a 10-line
     blockquote produced 10 stacked `<blockquote>` tags with no grouping
  3. When a fenced code block sat inside a blockquote, the fence-stash
     pass consumed the code content and left orphaned `>` lines that the
     old `.+` pattern could not match

Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.

14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph

* fix(renderer): drop empty trailing line from blockquote match

The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.

Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.

Reproducer:
  '> Hello\n\nThe rest of the message.'
    → '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
                          ^^^ phantom <br>

Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).

Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
  - input ending with \n
  - quote followed by paragraph (the real-world case)
  - multi-line quote ending with \n
  - quote with blank continuation + trailing \n (internal <br> stays,
    trailing <br> does not)

Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes

Five additional fixes on top of the blockquote grouping from the initial commit:

1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
   line endings do not produce stray \r characters in rendered output

2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
   inside blockquotes/lists) and the outer pass (for plain paragraphs).
   Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.

3. Task lists: - [x] / - [ ] items in unordered lists render as /☐
   via task-done/task-todo span wrappers. Checks [X] (uppercase) too.

4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
   own <blockquote> element rather than passing through as literal >.
   Implemented by extracting the blockquote rule into _applyBlockquotes()
   which calls itself recursively on the stripped inner content.

5. Lists inside blockquotes: > - item now renders <ul><li> inside the
   blockquote instead of a literal "- item" string. Task list items work
   inside blockquotes too (> - [x] done →  inside <blockquote><ul>).

Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.

68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.

* fix(renderer): restore space in blockquote prefix-strip regex

Commit 04e7b53 changed the blockquote prefix-strip regex from
  /^>[ \t]?/   (consume "> ", "\t>", or just ">")
to
  /^>[\t]?/    (only consume "\t>" or just ">")

The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?:  )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.

Reproducer (against the actual ui.js via node, before the fix):
  > Hello world         → <blockquote> Hello world</blockquote>
                                       ^ phantom leading space
  > Steps:              → <blockquote>Steps:
  > - one                  - one
  > - two                  - two</blockquote>
                          ^ literal text, NOT a <ul>; lists-in-quote feature broken
  > - [x] done          → blockquote with literal "[x] done", no checkbox span

Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.

Fix: restore the space character in the strip regex's character class.

Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).

Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.

Suite: 2354 passed, 0 new failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Collapse agent session compression chains

* Restore upstream changelog entries

* fix(agent_sessions): bubble active compression chains to top by tip last_activity

The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.

Reproducer (with the harness against actual code, before the fix):
  - root: started 30 days ago, last msg 30 days ago
  - tip:  started 28 days ago (parent_session_id=root), last msg 5 seconds ago
  - standalone: last msg 2 days ago

  Sidebar order with original PR:
    [0] standalone  (48h ago)
    [1] active_tip  (last_activity=root's 720h ago)  ← wrong

  Sidebar order after fix:
    [0] active_tip  (last_activity=tip's 0h ago)     ← correct
    [1] standalone  (48h ago)

This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.

Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.

The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: v0.50.216 release notes and version bump

Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.

* chore: gitignore local-only review harness directory

Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Keep reasoning chip visible for None effort

* test(reasoning): pin chip render output via node, not just source regex

The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.

Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:

  TestChipAlwaysVisible
    - empty / null  -> "Default" label, inactive=true
    - "none"        -> "None" label, inactive=true
    - "low"/"high"  -> verbatim label, inactive=false
  TestNormalizationEdgeCases
    - "NONE"        -> normalises to "None"
    - "  none  "    -> trims and normalises
    - unknown junk  -> falls through visible, never hidden
  TestTitleAttributeAccessibility
    - title attribute carries the human-readable label for tooltip /
      screen-reader use

Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.

This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: add #1074 to v0.50.216 changelog, bump test count to 2428

* fix(i18n): restore broken Unicode in Russian and Spanish steer strings

Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:

  Spanish (es):
    - cmd_steer:                   correcci00f3n  → corrección
    - cmd_steer_fallback:          2014 en cola   → — en cola
    - busy_steer_fallback:         2014 en cola   → — en cola
    - settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
    - settings_busy_input_mode_steer: correcci00f3n  → corrección

  Russian (ru):
    - settings_desc_busy_input_mode: the entire Cyrillic string was
      replaced with raw 4-hex-char code-points without the \u prefix
      (041e043f... instead of actual Cyrillic). Decoded:
      "Определяет поведение при отправке сообщения во время работы
      агента. Очередь ждёт; Прерывание отменяет и начинает заново;
      Steer внедряет коррекцию без прерывания."

Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).

All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.

Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: remove duplicate compression chain entry from [Unreleased]

---------

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>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-25 21:06:31 -07:00
nesquena-hermes 3d96dc1498 v0.50.215: real /steer via agent.steer() — mid-turn correction without interrupt (#1069)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-04-25 19:21:00 -07:00
nesquena-hermes 520034c071 v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067)
* feat: busy input modes with queue/interrupt/steer slash commands

- Add busy_input_mode setting (queue/interrupt/steer) to config defaults
- Add /queue, /interrupt, /steer slash commands with handlers
- Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior)
- Add settings dropdown in settings panel with load/save/apply wiring
- Initialize window._busyInputMode at boot and on settings save
- Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant)
Addresses #720

* test: 17 regression tests for busy_input_mode + slash commands

PR description noted manual testing only. Added structural tests
matching the pattern used by recent contributor PRs (#1010, #1011,
#1018, #1022, #1058) so future refactors don't silently regress
the wiring:

  Backend (api/config.py):
    - default 'queue' is set in _DEFAULT_SETTINGS
    - enum validator restricts to {queue, interrupt, steer}

  Slash commands (static/commands.js):
    - /queue, /interrupt, /steer all registered with correct fns
    - /interrupt and /steer set noEcho:true (the queued payload
      becomes the visible turn, not the slash invocation)
    - cmdQueue requires S.busy
    - cmdInterrupt + cmdSteer call queueSessionMessage before
      cancelStream (otherwise the drain has nothing to pick up)

  send() busy branch (static/messages.js):
    - reads window._busyInputMode
    - calls cancelStream on interrupt/steer
    - queues before cancelling (ordering invariant)

  Boot init + panels.js wiring (static/boot.js, static/panels.js):
    - both success and fallback paths set window._busyInputMode
    - load/save/apply path threads busy_input_mode through

  i18n (static/i18n.js):
    - all 17 new keys present in each of the 6 locale blocks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers

1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text
   as a user bubble, then the drain would send the queued message, causing a
   double-bubble in the conversation (#840 pattern).

2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue
   payload but never cleared S.pendingFiles or called renderTray(). Staged files
   would remain in the tray and be re-attached on the next send(), duplicating
   attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge().

3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only
   interrupt + steer), now documents that all three must set noEcho:true.

4. test_slash_commands_clear_pending_files: new test that all three handlers clear
   S.pendingFiles and call renderTray() after enqueuing.

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>

* docs: v0.50.214 release notes and version bump

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
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>
2026-04-25 18:51:06 -07:00
nesquena-hermes 360463dd8e v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* 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>
2026-04-25 18:24:30 -07:00
nesquena-hermes 01404ac062 v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* 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>
2026-04-25 17:50:58 -07:00
nesquena-hermes 6c343aff84 v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs

Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.

Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent

* fix(models): add gpt-5.5-mini to copilot provider catalog

* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)

Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.

Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.

Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.

3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.

* fix(onboarding): non-standard provider/path cluster (#1029)

* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)

The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.

Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:

  syncTopbar()              — restores model chip, title, topbar state
  syncWorkspacePanelState() — restores workspace panel open/closed
  _initResizePanels()       — reattaches panel resize drag listeners
  startGatewaySSE()         — reconnects the gateway SSE watcher
                              (bfcache-persisted connections are dead)

All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.

loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.

7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.

* fix(bfcache): also close open dropdowns on bfcache restore (#1045)

Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.

Extend the pageshow handler to call all four named close functions before
the layout sync:
  closeModelDropdown()     — composer model selector
  closeReasoningDropdown() — thinking/reasoning effort selector
  closeWsDropdown()        — workspace chip dropdown
  closeProfileDropdown()   — profile switcher dropdown

All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.

2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)

2221/2221 tests passing.

* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners

* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test

* fix(sessions): use cron job name as session title when available (#1032)

* fix(test): add id column to messages table in cron title test fixture

* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block

* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)

When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.

Fix: intercept 401 responses client-side before surfacing any error.

- workspace.js api(): check res.status===401 first; call
  window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
  calls that bypass api() — api/models, api/models/live, api/upload

All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.

6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.

* fix(pwa): preserve current URL in ?next= param on 401 redirect

* fix(test): update 401-redirect assertion to accept ?next= URL format

* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login

Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.

* Implement session agent cache for AIAgent reuse

Added session agent cache to reuse AIAgent across messages.

* Implement agent caching for session management

* Implement session agent eviction on session deletion

Added session agent eviction to prevent turn count leakage in recycled sessions.

* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)

* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210

Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:

  - Mermaid CSP font fix (#1044)        → v0.50.210 / Mermaid Google Fonts CSP
  - bfcache layout restore (#1045)      → v0.50.210 / bfcache layout and dropdown restore
  - iOS PWA auth redirect (#1038)       → v0.50.210 / Login redirects back to original URL

The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:47:44 -07:00