Commit Graph

364 Commits

Author SHA1 Message Date
nesquena-hermes ad8e10304c v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* 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>
2026-04-25 13:07:35 -07:00
nesquena-hermes 12a8c051fb fix: inject full workspace path into agent context for uploaded files (#997)
fix: inject full workspace path into agent context for uploaded files (#997)

Uploaded files (drag-and-drop or paperclip) were saved correctly to the workspace
but the agent message only contained the bare filename — `photo.jpg` instead of the
full path. The agent couldn't call `read_file` or `vision_analyze` without a full path.

`uploadPendingFiles()` now returns `{name, path}` objects from `/api/upload`
(`data.path` was always returned, just never threaded through). The agent message
gets the full absolute path; all display surfaces (badges, session history, INFLIGHT
state, POST body) continue showing only the bare filename.

Three fixes absorbed during review:
- Second `saveInflightState()` call was passing raw `{name,path}` objects instead
  of the `uploadedNames` string array (INFLIGHT localStorage corruption on page reload)
- `attachLiveStream()` was being called with the raw object array; changed to pass
  `uploadedNames` so the `done` handler receives strings, not objects
- `attachLiveStream` `done` handler referenced `uploadedNames` which is out of scope
  there (ReferenceError on every upload success); fixed to use the `uploaded` param

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Closes #996
2026-04-24 23:09:44 -07:00
Basit Mustafa e62338d3a0 fix(queue): drain correct session queue after cross-session stream completion (#964)
When a session finishes streaming while the user has switched to a different
session, setBusy(false) was draining S.session.session_id (the currently
*viewed* session) instead of the session that actually finished. Queued
follow-up messages were silently dropped.

Root cause: setBusy() has no context about which session triggered it.
The activeSid closure variable inside attachLiveStream() knew the right
session but was not propagated.

Fix: add _queueDrainSid module global (null by default). Stream done and
error handlers set it to activeSid immediately before calling setBusy(false).
setBusy(false) reads and clears _queueDrainSid, falling back to S.session if
it is unset (the common case where the user hasn't switched away).

Handlers patched: done event, start-call error handler, stream_end/stream_stop
reconnection fallback, and max-retry error exit.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 12:33:56 -07:00
Basit Mustafa a4b56642d9 perf(streaming): throttle inflight localStorage persist to prevent GC crash (#972)
saveInflightState() is called from syncInflightAssistantMessage() on every
token. It does localStorage.getItem + JSON.parse + mutate + JSON.stringify +
localStorage.setItem on the full inflight state map. For a 5000-token response
with a 10KB messages array this produces ~36MB of JSON churn per second.

This O(response_length) work per token is the primary source of GC pressure
that causes the renderer to crash (Chrome error codes 4/5). The 13.6-second
RunTask we observed in perf traces is a direct consequence: accumulated rAF
callbacks execute all at once after each multi-second GC pause.

Fix: add _throttledPersist() which writes at most once every 2 seconds during
token streaming. State transitions that matter for crash recovery (tool events,
done, start) still call persistInflightState() directly, so at most 2s of
in-flight progress is lost if the tab crashes mid-stream.

The _persistTimer is cleared on 'done' so the final state is always flushed.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 12:33:16 -07:00
nesquena-hermes 86b20d362f fix(streaming): call clearTimeout at all _pendingRafHandle cleanup sites (#985)
_scheduleRender() now uses setTimeout(→rAF) when within the 66ms throttle
window, meaning _pendingRafHandle can hold a setTimeout ID (not a rAF ID).
All 4 cleanup sites only called cancelAnimationFrame(), which is a no-op for
timeout handles, leaving stale callbacks that could fire after stream end.
Fix: call both clearTimeout() and cancelAnimationFrame() at each site.
(clearTimeout is a no-op when called with a rAF handle, and vice versa.)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:57:48 -07:00
Basit Mustafa 6333a06524 perf(ui): cache renderMessages per session, skip O(n) rebuild on back-navigation (#963)
renderMessages() tears down and rebuilds every message's DOM from scratch on
every call — renderMd() (markdown parse), Prism highlight, and KaTeX per
message, O(n) total. With large sessions the main thread blocks for 1-5
seconds on each call.

A Chrome perf trace (78s, many open sessions) showed:
- 9,373ms of GC across 34,049 GC events (sustained, not burst)
- Peak 273 messages.js FunctionCalls/second
- 4.7s, 3.5s, 3.2s main-thread blocks from repeated renderMessages invocations

The render bottleneck is unaddressed by PR #959 (which improves the network/
parse leg of session switching, not the render leg).

Fix: a session-keyed innerHTML cache. After a full rebuild, the rendered HTML
is stored against the session_id + message count. When switching back to a
session that was already rendered with the same count, the DOM is restored from
cache (fast innerHTML set + re-highlight) instead of rebuilt from scratch.

Guard: the cache is only used on cross-session navigation (sid !== current).
In-session updates (new messages, edits, tool_complete, stream events) always
get a full rebuild — no stale content is ever shown.

Cache is capped at 30 sessions and evicts oldest-first to bound memory.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 11:49:14 -07:00
Basit Mustafa 0217bf5cce perf(streaming): throttle live render to ~15fps to prevent crash under GC pressure (#966)
_scheduleRender() uses requestAnimationFrame to update the live assistant
message during streaming. rAF fires at up to 60fps, but each DOM update
takes 50-150ms on sessions with long histories — far exceeding the 16ms
rAF budget.

During GC pauses (which can run for hundreds of milliseconds), rAF
callbacks accumulate. When the GC yields, the browser executes all
queued callbacks sequentially in a single RunTask. A Chrome performance
trace shows a 13.6-second RunTask containing 1,240 accumulated render
callbacks — which causes the renderer to crash (Chrome error codes 4/5,
ERR_EMPTY_RESPONSE / ERR_CONNECTION_RESET).

Fix: track the last render timestamp and delay scheduling the next rAF
until at least 66ms (15fps) have elapsed since the previous render.
If within the 66ms window, use setTimeout to defer the rAF rather than
skipping it — this batches token updates without dropping any content.

The 66ms interval is conservative enough to prevent runaway accumulation
while fast enough that streaming text still feels immediate. The _renderPending
flag continues to prevent double-scheduling within each interval.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 11:44:47 -07:00
nesquena-hermes 116a510ed3 i18n: add complete Traditional Chinese (zh-Hant) translations (#954)
* 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>
2026-04-24 11:36:41 -07:00
Josh Jameson 7e17ec497c fix: fast conversation switching with metadata-first load (#959)
- 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
2026-04-24 11:35:14 -07:00
bsgdigital a2d7f311be fix(streaming): prevent dropped characters in incremental smd path (#960)
Detect prefix desync between current display text and already-streamed text, then rebuild the streaming-markdown parser from full content to avoid character loss during live rendering. Add regression assertions for the new desync guard.

Made-with: Cursor

Co-authored-by: bsgdigital <bsg@bsgdigital.com>
2026-04-24 11:04:32 -07:00
bsgdigital e5cf9c5910 fix(streaming): strip malformed DSML function_calls tags (#958)
Handle DeepSeek DSML variants including truncated and spaced tag forms, and sanitize thinking-card text so leaked XML fragments never render. Add regression tests for DSML edge cases and thinking-card sanitization.

Made-with: Cursor

Co-authored-by: bsgdigital <bsg@bsgdigital.com>
2026-04-24 11:04:16 -07:00
ruxme f109592cb0 perf: add defer to all local script tags (#951)
All 10 local <script> tags now use the defer attribute, allowing the
browser to download them in parallel during HTML parsing instead of
blocking the DOM sequentially. Execution order is preserved.

Before: scripts loaded one-at-a-time, each blocking DOM construction
After:  scripts downloaded in parallel, executed in order after DOM ready

Fixes slow sidebar session list rendering on initial page load.

Co-authored-by: 陳俊宇 <chenjunyu@chenjunyudeMacBook-Air-7.local>
2026-04-24 11:03:59 -07:00
Basit Mustafa b072a6887c fix(csp): add explicit manifest-src 'self' directive (#961)
PR #920 added static/manifest.json and sw.js for PWA support. The CSP
in _security_headers() had no explicit manifest-src directive, so browsers
fell back to default-src 'self' and emitted a console warning on every page
load. The fallback is functionally correct but non-compliant with CSP Level 3
best practice of declaring each directive explicitly.

Adds manifest-src 'self' before base-uri. No origin set is changed.
Regression test added alongside existing CSP coverage in test_pwa_manifest_csp.py.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 10:44:46 -07:00
bergeouss 23e9070fc5 fix(btw): use correct SSE endpoint /api/chat/stream (#950)
The /btw command was completely non-functional because attachBtwStream()
connected to /api/stream which doesn't exist — the server SSE handler
lives at /api/chat/stream. This caused an immediate 404 on every /btw
request.

Closes #945

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 10:43:44 -07:00
nesquena-hermes 533edbcae0 fix(ui): close 641-767px rail/hamburger breakpoint gap (#956)
At 641-767px the sidebar was in a no-mans-land: hamburger hidden (<=640 only)
and rail also hidden (>=768 only). Users could still navigate via the sidebar-nav
tabs inside the sidebar, but the rail was absent unnecessarily.

Changing the rail breakpoint from min-width:768px to min-width:641px closes the
gap. The sidebar slide-in behavior (position:fixed, hamburger toggle) stays at
<=640px only, so the mobile UX is unchanged. At 641-767px the rail now appears
alongside the persistent sidebar.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 09:13:00 -07:00
Aron Prins 970bc1d3fd refactor(ui): three-column layout with left rail + main-view migration (#899)
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>
2026-04-24 09:05:25 -07:00
nesquena-hermes 061af78cde v0.50.185: /btw stream hardening + .venv bootstrap + /reasoning toast (#935 #939 #941 #942)
* fix(bootstrap): discover .venv layout in agent_dir (closes #938) (#941)

* fix(btw): harden _streamDone flag — defensive ordering + session guard + stream_end coverage (#935)

* fix(btw): align /reasoning toast prefix with BRAIN const (#939)

* docs: v0.50.185 release notes, update test counts to 2107

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 23:25:45 -07:00
nesquena-hermes 87d4136a43 fix(ui): move reasoning chip after model chip in composer footer (#937)
Reasoning is a sub-setting of the model (applies only to models that
support it), so the model should come first. This also keeps the model
chip in a stable position regardless of whether reasoning is active.

Order was: Profile → Workspace → Reasoning → Model
Order now:  Profile → Workspace → Model → Reasoning

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 19:43:41 -07:00
nesquena-hermes 1a9dba7844 fix: reasoning chip dropdown visible + monochrome SVG icon + /btw answer preserved (closes #933) (#934)
* fix: reasoning chip dropdown visible + SVG icon + /btw answer no longer wiped (closes #933)

* fix(ui): resize handler symmetry + lock regressions for PR #934 fixes

Two small additions on top of the core PR:

1. Resize handler now re-positions the reasoning dropdown when the window
   resizes while it's open, matching the existing model-dropdown branch.
   Without this, resizing while the dropdown is open leaves it aligned to
   the pre-resize chip position — fine in practice (most resizes close the
   dropdown via the global click handler) but inconsistent with the
   model-dropdown sibling.

2. Regression test file tests/test_reasoning_chip_btw_fixes.py with 10
   tests locking all four fixes in place so they can't silently regress:

   - Dropdown sits OUTSIDE .composer-left (so overflow-y: hidden can't clip it)
   - Dropdown is grouped with the other composer-level dropdowns
   - Chip button contains stroke="currentColor" SVG (not a 🧠 emoji)
   - _applyReasoningChip() body doesn't include 🧠
   - cmdReasoning calls _applyReasoningChip(eff) directly with the
     server-confirmed effort, not syncReasoningChip() (stale cache)
   - _streamDone flag declared, set in done handler, checked in onerror
   - _ensureBtwRow() called in done handler (creates bubble when no tokens arrive)
   - resize handler re-positions composerReasoningDropdown

Full suite: 2056 passed, 0 failed.

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: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:18:51 -07:00
nesquena-hermes 9c69b646ff feat(commands): /background, /btw slash commands + undo button + reasoning chip
Rebased onto master after #931 (aux title routing) to resolve streaming.py conflict.
All changes from both PRs are cleanly integrated.

2088 tests passing (2065 master + 23 from #931).

Co-authored-by: bergeouss <bergeouss@gmail.com>
2026-04-24 01:24:51 +00:00
nesquena-hermes b14ea4f9f6 chore: vendor streaming-markdown@0.2.15, remove CDN dependency
Self-hosts smd.min.js (12,586 bytes, sha384 verified against npm tarball).
App works fully offline/air-gapped. Static server correctly serves static/vendor/*.

Co-authored-by: bsgdigital <bsgdigital@users.noreply.github.com>
2026-04-24 01:05:20 +00:00
Nathan Esquenazi b563484a56 fix(smd): strip javascript:/data:/vbscript: URLs — smd does not sanitize schemes
streaming-markdown@0.2.15 preserves arbitrary URL schemes in href/src.
Verified with a Node + jsdom harness:

  IN : [click](javascript:alert(1))
  OUT: <p><a href="javascript:alert(1">click</a>)</p>        ← XSS vector

Confirmed unsafe for: javascript:, vbscript:, data:text/html, file://.
The library uses only safe DOM primitives (createElement/appendChild/
createTextNode — no innerHTML/eval), so <script> tags are escaped as
text, but URL-scheme filtering is absent. The existing renderMd() path
implicitly filtered to http(s) via its regex, so this is a regression
the moment streaming markdown is enabled.

Attack path: agent echoes prompt-injection content containing a
markdown link with javascript: href → smd renders it live → user clicks
during the streaming window → JS executes in webui origin → session
cookie, API calls, etc.

Fix: walk the live DOM after each parser_write (and again after
parser_end) and remove href/src attributes whose scheme isn't on the
safe allowlist (http, https, mailto, tel, and relative/anchor paths).
Blocked anchors keep their text content but lose href; blocked images
lose src and get data-blocked-scheme="1" for debugging.

Harness confirms all 10 tested cases behave correctly — javascript:,
vbscript:, data:text/html, file:// all stripped; https://, /path,
#anchor, mailto:, tel: all preserved.

Added 5 regression tests in TestSmdUrlSchemeSanitization that lock:
  - the sanitize helper exists
  - the allowlist regex permits https? and forbids javascript/vbscript/data:
  - _smdWrite invokes sanitize after parser_write
  - _smdEndParser invokes sanitize after parser_end
  - the sanitizer covers both <a href> and <img src>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:28:40 -07:00
nesquena-hermes 89b0c8eb41 feat: incremental streaming markdown via streaming-markdown (v0.50.180, #917)
Co-authored-by: bsgdigital
2026-04-23 23:09:08 +00:00
nesquena-hermes 1011918d50 feat: add PWA support (manifest, service worker, install prompt) (#920)
* feat: add PWA support (manifest, service worker, install prompt) (v0.50.178, #911)

Co-authored-by: bsgdigital
Closes #685

* fix(sw): await caches.match() before `|| fallback` so offline HTML actually shows

The offline-navigation fallback was dead code:

    return caches.match('./') || new Response('<html>...</html>', ...);

`caches.match()` returns a Promise, and Promise objects are always truthy
in a `||` check — so the `new Response(...)` branch was never taken. On
actual offline, `caches.match('./')` resolves to undefined (no cache hit
for the root), the SW returns undefined, and the browser falls back to
its own default offline page. The custom "Hermes requires a server
connection" HTML was unreachable.

Fix by threading the match through `.then()` so the resolved value (not
the Promise object) feeds the `||`:

    return caches.match('./').then((cached) => cached || new Response(...));

Added 13 regression tests in tests/test_pwa_manifest_sw.py covering:
- manifest.json validity + required PWA fields + icon existence
- sw.js cache-version placeholder + API/stream bypass + correct offline
  pattern (explicitly rejects the broken `|| new Response` shape so it
  can't regress)
- /manifest.json + /sw.js routes serve correct Content-Type,
  Cache-Control, Service-Worker-Allowed headers and inject WEBUI_VERSION
- index.html links manifest, registers SW, has iOS PWA meta tags

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: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:14:21 -07:00
nesquena-hermes 07caaec6ef fix(mobile): adapt settings dialog and message controls for mobile screens (#919)
* fix(mobile): adapt settings dialog and message controls for mobile screens (#915)

Co-authored-by: bsgdigital

* fix(mobile): adapt settings dialog and message controls for mobile screens (v0.50.177, #915)

Co-authored-by: bsgdigital

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 15:12:07 -07:00
nesquena-hermes 1175ee363f fix(models): duplicate dropdown entries, stale default model, lowercase injected label (#907 #908 #909) (#918)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 14:41:06 -07:00
nesquena-hermes 5082f426f2 fix: correct interleaved streaming order (Text → Thinking → Tool → Text) (#913)
* fix: correct interleaved streaming order (Text → Thinking → Tool → Text)

During live streaming, tool cards were inserted before their associated
thinking cards instead of after them. The root cause was that
appendLiveToolCard's anchor selector didn't include .thinking-card-row,
so finalized thinking cards were skipped when finding the insertion point.

Changes:
- messages.js: Add segment splitting (segmentStart/_freshSegment) so each
  text segment after a tool call renders only its own slice, not the full
  accumulated text. Sync thinking card render in reasoning handler to
  avoid rAF race with tool events. Guard removeThinking() to preserve
  finalized cards when reasoningText is active.
- ui.js: Add .thinking-card-row to appendLiveToolCard anchor selector so
  tool cards land after finalized thinking. Add anchor-based positioning
  to appendThinking for correct interleaved placement. Clean up empty
  spinner-only thinking rows in finalizeThinkingCard. Add 3-dot waiting
  indicator (toolRunningRow) after tool cards for visual feedback.
- style.css: Scope blinking cursor to last live-assistant segment only.
  Add spacing for toolRunningRow.

* chore: CHANGELOG for v0.50.174

---------

Co-authored-by: bsgdigital <bsgdigital@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 13:23:43 -07:00
nesquena-hermes 537c8271db fix(renderer): ordered list items always showed 1. — emit value= on each li (#886) (#904)
* fix(renderer): ordered list items always showed 1. — emit value= on each <li> (#886)

Root cause: when LLMs output numbered lists with blank lines between items,
renderMd()'s paragraph-splitter (split(/\n{2,}/)) breaks the markdown into
one chunk per item. The ordered-list regex then wraps each item in its own
<ol>, and since each <ol> restarts at 1, the rendered output is always 1. 1. 1.

Fix: capture the original number from each list line and emit value="N" on
every <li>. The HTML spec guarantees that value= overrides the <ol> counter,
so even items in separate <ol> containers display their correct ordinal.

6 regression tests in tests/test_886_ordered_list_numbering.py.
1958 tests pass.

* chore: add v0.50.173 CHANGELOG entry for ordered list fix

---------

Co-authored-by: Hermes Bedrock Fix <hermes-fixes@local>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 12:15:56 -07:00
nesquena-hermes 4089972b09 fix(models): preserve @nous: prefix in settings + fix cross-namespace 404 for Nous (#895 #894) (#901)
* fix(models): preserve @nous: prefix in settings + fix cross-namespace 404 for Nous (#895 #894)

* fix(review): persist bare form for CLI compatibility + picker smart-match

The PR persisted `@nous:anthropic/claude-opus-4.6` verbatim to config.yaml
to make the Settings picker match its dropdown options (which carry the
`@nous:` prefix after #885). That fixes the WebUI picker but introduces a
cross-tool regression: hermes-agent's CLI reads `config.yaml -> model.default`
directly and passes it to the provider API verbatim. For aggregator providers
(Nous is one — see hermes_cli/model_normalize.py `_AGGREGATOR_PROVIDERS`),
`normalize_model_for_provider` is skipped entirely (run_agent.py:887), so
the literal `@nous:anthropic/...` string flows to the Nous API, which rejects
it — breaking every user who runs `hermes` in the terminal right after
saving via WebUI.

Fix the tension at the picker rather than the persistence: the existing
`_findModelInDropdown()` smart matcher already normalises both sides
(lowercase, strip namespace prefix, dashes→dots) so a saved bare
`anthropic/claude-opus-4.6` resolves to the `@nous:anthropic/claude-opus-4.6`
option automatically. Applied this in panels.js via `_applyModelToDropdown()`.

Changes:
  api/config.py         revert the @-prefix preservation; persist the
                        resolved bare/slash form (CLI-compatible)
  static/panels.js      Settings picker uses _applyModelToDropdown()
                        instead of raw `.value =` so saved bare forms
                        still select the matching @nous: option
  tests                 test renamed + asserts bare persisted form;
                        new test locks the smart-matcher contract

This also improves behaviour for a dormant case not flagged in #895: a user
who set their default via `hermes model X` and opens Settings for the first
time used to see a blank picker (bare form vs prefixed options). Now the
smart matcher finds the right option, so the "open Settings → save → bare
form in config.yaml" round-trip is stable for both CLI- and WebUI-origin
saves.

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

* chore: update CHANGELOG v0.50.171 — bare-form persistence + picker smart-match

---------

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-23 10:44:10 -07:00
nesquena-hermes 498156a3e8 fix(settings): show live models in default model picker and apply to new chats (#872) (#900)
* 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>
2026-04-23 09:58:15 -07:00
bergeouss bd443c4862 fix(markdown): stash code blocks with attributes and multiline content (#890) (#891)
The _ob_stash regex in renderMd() used (<code>[^<]*</code>) which failed
to match <code class="language-sql"> tags (attributes) and couldn't capture
multiline content. Code blocks leaked into the bold/italic pipeline,
corrupting SQL/C# comments into <strong><em> tags and producing &lt;
artifacts.

Replace with (<code\b[^>]*>[\s\S]*?</code>) to handle attributes and
multiline content correctly.

Closes #890

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 09:45:20 -07:00
nesquena-hermes b82954ee70 feat(ui): session attention indicators — streaming spinner, unread dot, timestamps (#856)
Closes #856. Co-authored-by: Frank Song <138988108+franksong2702@users.noreply.github.com>
Reviewed-by: nesquena (709bd37 — test isolation fix also included)
2026-04-23 09:05:57 -07:00
nesquena-hermes d39d30a213 fix: correct message ordering after task cancellation — v0.50.163 (#883)
fix: correct message ordering after task cancellation — v0.50.163 (#883)

Fixes the message-ordering glitch from #882: clicking Cancel while the
agent is responding could cause a subsequent response to render above
the "*Task cancelled.*" marker.

Root cause: the cancel handler pushed the marker only to local S.messages
without persisting to the server. When the done event fired shortly after
and replaced S.messages from server state, the marker disappeared from
client state while the next response anchored to the server-authoritative
position.

Fix has three parts:
- Server (cancel_stream): append *Task cancelled.* to session.messages
  with _error:True + timestamp, then save. _error ensures
  _sanitize_messages_for_api() strips it from conversation_history on
  the next agent turn, so the LLM never sees it as a prior assistant
  turn. Precedent: same flag used for the apperror marker at line 1343.
- Client (SSE cancel handler): fetch /api/session instead of pushing
  locally (same pattern as the done handler). Falls back to local push
  if the fetch fails.
- Tests: fix test window width for cancel handler (1200→dynamic); add
  two regression tests pinning _error flag and _sanitize invariant.

1941 tests passing.

Co-authored-by: piliang <piliang1@jd.com>
2026-04-22 22:17:40 -07:00
Frank Song 62c56175b7 feat(workspaces): autocomplete trusted workspace paths — v0.50.162 (PR #880 by @franksong2702, closes #616)
Adds GET /api/workspaces/suggest endpoint and autocomplete dropdown in the Spaces panel. Suggestions limited to trusted roots (home, saved workspaces, boot default). Keyboard nav, Tab completion, hidden dir support. Symlink-escape and dotdot-escape invariants locked by regression tests.
2026-04-23 02:35:58 +00:00
nesquena-hermes cc025aab79 fix(ci): add missing provider i18n keys to non-English locales — v0.50.160
Adds 19 provider panel keys (English fallback) to es, de, zh, ru, zh-Hant. Fixes locale parity CI failures since v0.50.159.
2026-04-23 01:24:11 +00:00
Pavol Biely 236a116888 fix(ux): selected text visible in user message bubbles + CI i18n fix — v0.50.160 (PR #877 by @pavolbiely)
User bubble selection contrast fixed via scoped ::selection CSS (closes #877). Also adds missing provider i18n keys to es/de/zh/ru/zh-Hant locales, fixing 3 CI failures that crept in from PR #867.
2026-04-23 01:19:21 +00:00
nesquena-hermes 04b00065f9 feat: provider key management from Settings — v0.50.159 (PR #867 by @bergeouss, closes #586)
New Providers tab in Settings lets users add/update/remove API keys without editing .env. Six review fixes applied. 18 tests.
2026-04-23 01:09:22 +00:00
nesquena-hermes e3607855b1 fix: poll /health after update instead of blind setTimeout — v0.50.158 (closes #874)
Replaces blind setTimeout reload with /health polling loop. Banner shows restart status with manual Reload button. Works behind reverse proxies. 25 regression tests.
2026-04-23 00:51:12 +00:00
nesquena-hermes 558b1730a6 fix: thinking card no longer mirrors main response — v0.50.154 (closes #852)
Remove early return in _streamDisplay() bypassing think-block stripping when reasoningText populated.
2026-04-22 20:21:42 +00:00
nesquena-hermes 201235d807 fix: live-fetched portal models route through configured provider — v0.50.153 (closes #854)
_fetchLiveModels() applies @provider: prefix to model IDs from portal providers.
2026-04-22 20:21:02 +00:00
nesquena-hermes 256b3fbbdf fix: image_generate renders inline + auto-title strips thinking preamble — v0.50.152 (closes #853, #857)
MEDIA: restore renders all https:// URLs as img (closes #853).
_strip_thinking_markup strips Qwen3 plain-text reasoning preambles (closes #857).
2026-04-22 20:20:01 +00:00
nesquena-hermes 5fa731ea4a release: v0.50.151 — credential_pool provider detection + Ollama Cloud support (PR #820 by @starship-s)
Surfaces providers added via credential_pool in the model dropdown. Ambient gh-cli tokens suppressed. _apply_provider_prefix helper extracted. Ollama Cloud display name + dynamic model list. looksLikeBareOllamaId heuristic tightened. Test isolation fixed.

PR #820 by @starship-s.
2026-04-22 20:18:02 +00:00
Miguel Tavares f42f1c69ca fix: correct webui profile switching state — v0.50.150 (PR #849 by @migueltavares)
Three related profile-switching fixes:
- Always persist hermes_profile=default cookie when switching back to default (was being cleared with max-age=0, causing fallback to process-global profile)
- Replace undefined updateWorkspaceChip() with syncTopbar() in the sessionInProgress branch of switchToProfile()
- Make sidebar/dropdown active-profile rendering prefer S.activeProfile client state when available, with safe fallback

Tests: 1854 passing.
2026-04-22 16:27:01 +00:00
nesquena-hermes 85434dd03c fix(appearance): font size setting now visibly scales UI text (closes #843)
* fix(appearance): font size setting now visibly scales UI text

Root cause: the original CSS override only changed :root{font-size} which
has no effect on the 232+ hardcoded px values throughout style.css. Only
the ~49 em/rem values were affected, which are not the main visible text.

Fix: add explicit px overrides for the key UI surfaces under each
data-font-size attribute selector:
  - .msg-body (chat messages) + headings, code, tables
  - .session-item, .session-meta (sidebar session list)
  - #msg (composer textarea)
  - .file-item (workspace file tree)

The :root override is kept so em/rem cascade correctly, but the targeted
element overrides are what actually make the text visibly larger/smaller.

Also: 8 new regression tests lock in the targeted CSS rules so this
cannot silently regress again.

* fix: composer large font was no-op — bump to 18px (default is 16px)

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 23:39:39 -07:00
nesquena-hermes db57c47ff3 fix(ui): slash command input now echoed as user message in chat (closes #840)
* fix(ui): echo slash command input as user message in chat (#840)

Slash commands like /skills, /help, /status previously showed only the
assistant response with no user message above it — the conversation
appeared to start from nowhere.

Fix: executeCommand() now returns {noEcho:bool} instead of true/false
(returns null when no command matched). send() in messages.js pushes a
user message bubble before returning when noEcho is false.

Commands with noEcho:true are action-only and don't get echoed:
/clear, /new, /stop, /retry, /undo, /voice, /model, /workspace,
/theme, /usage, /reasoning.

Commands without noEcho (get echoed):
/help, /skills, /status, /title, /compress, /compact, /personality.

16 new tests in test_issue840_slash_echo.py.

* fix(ui): push user message BEFORE running slash handler (ordering bug)

The PR as originally written pushed the user message AFTER the slash
command handler ran.  That works correctly for async handlers (the
assistant response lands later, after the user push) but breaks for
sync handlers like cmdHelp which push their assistant response
synchronously:

  S.messages = [assistant response, user "/help"]   ← reverse order

The chat would render the help content ABOVE the user's own "/help"
input — not what the issue asked for.

Fix: look up the command inline, push the user message first (for
echo-worthy commands), then run the handler.  If the handler opts out
(returns false — e.g. /reasoning <level>), pop the user message back
off so the normal send path can add it cleanly when forwarding to the
agent.

Renamed the flow so it's clear we're not calling executeCommand twice
(my first attempt did that by accident).  executeCommand() stays as a
public API returning null or {noEcho:bool} — just isn't the only path
send() uses now.

Added 2 regression tests:

- test_send_pushes_user_message_before_running_handler: asserts
  the user push appears before the handler invocation in source order.
- test_send_rolls_back_user_push_on_handler_optout: asserts the
  S.messages.pop() for the opt-out case.

Also tightened the existing `test_send_checks_noecho_flag` and
`test_send_pushes_user_message_for_echo_commands` tests to look at
the new `_cmd.noEcho` pattern inline (vs the original
`cmdResult.noEcho`).  Removed `test_send_uses_null_check_not_truthy`
(obsoleted — the control flow no longer stores the executeCommand
return in a variable).

Full suite: 1767 passed, 0 failures.

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

* fix(ui): compress/compact noEcho + title/personality confirmation messages

Applied Opus mentor review fixes:
- compress and compact: add noEcho:true (S.messages reset internally causes
  user bubble to flicker/disappear without noEcho)
- /title <name>: push assistant confirmation message after rename succeeds
- /personality <name>: push assistant confirmation message after set succeeds
- 4 new regression tests covering the above invariants

---------

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-21 23:08:24 -07:00
nesquena-hermes 9b628c27ab fix(ui): scroll selected item into view on slash command dropdown keyboard navigation (closes #838)
* fix(ui): scroll selected item into view on slash command dropdown keyboard nav

navigateCmdDropdown() in commands.js now calls scrollIntoView({block:'nearest'})
after updating the .selected class, so the highlighted item stays visible
when the dropdown overflows and the user navigates with ↓/↑. Closes #838.

* test: lock in scrollIntoView for slash command dropdown navigation (#838)

4 regression tests in test_cmd_dropdown_scroll_838.py:
- navigateCmdDropdown calls scrollIntoView on the selected item
- Uses {block:"nearest"} (minimum-distance scroll, not jumpy)
- Scroll call comes AFTER the .selected classList.add (correct target)
- .cmd-dropdown has overflow-y:auto so the dropdown itself is the scroll
  container (scrollIntoView does not bubble up to the viewport)

Full suite: 1749 passed, 0 failures.

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: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:55:09 -07:00
nesquena-hermes 11fd0d8412 feat(tasks): refresh button in cron panel + auto-refresh on job creation (closes #835)
* feat(tasks): refresh button in cron panel + hermes:cron_created event

Add a ↺ refresh button to the Scheduled Jobs header so the job list can
be reloaded without a full page refresh. Closes #835.

- static/index.html: ↺ button with cronRefreshBtn id, calls loadCrons(true)
- static/panels.js: loadCrons(animate) dims+disables the button while fetching,
  restores it in finally; hermes:cron_created window event auto-refreshes list
  when the agent creates a job from chat

* test: add regression tests for cron refresh button + event listener

The PR shipped without automated coverage (pure UI wiring).  Filling that
gap with 8 source-level tests:

- Refresh button element exists with aria-label + title (icon-only a11y)
- Button wires onclick to loadCrons(true) for the dim animation
- Button sits in the same header row as "New job"
- loadCrons() now accepts an animate parameter
- loadCrons() restores the button's opacity/disabled in finally (so a
  throwing fetch doesn't leave the button stuck)
- hermes:cron_created window listener is registered at module scope
- Listener calls loadCrons() when dispatched

Also rebased onto master (CHANGELOG conflict resolved — v0.50.143 →
v0.50.142 since master's top is currently v0.50.141).

Full suite: 1750 passed, 0 new failures.

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: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:54:06 -07:00
nesquena-hermes 24fc9d4155 feat(appearance): font size setting with Small/Default/Large toggle (closes #833)
* feat(appearance): font size setting with Small/Default/Large toggle

Add a font size preference to the Appearance settings pane.
Three options (12px/14px/16px) follow the same three-button visual
pattern as the Theme picker. Closes #833.

- static/style.css: :root[data-font-size=small|large] CSS overrides
- static/index.html: boot script applies from localStorage before CSS
  renders (no FOUC); fontSizePickerGrid HTML in Appearance pane
- static/boot.js: _applyFontSize(), _pickFontSize(), _syncFontSizePicker()
- static/panels.js: loadSettingsPanel syncs picker on open;
  _revertSettingsPreview restores on discard
- static/i18n.js: settings_label_font_size + font_size_{small,default,large}
  keys in all 6 locales (en, ru, es, de, zh, zh-Hant)
- tests/test_font_size_setting.py: 14 new tests

* fix(ui): remove duplicate font-size picker + correct CHANGELOG issue ref

Two small fixes on the font size feature:

1. Duplicate HTML IDs — the picker block was injected into BOTH
   settingsPaneAppearance (correct, next to Theme/Skin) AND
   settingsPanePreferences (accidental copy-paste).  Duplicate IDs
   #fontSizePickerGrid and #settingsFontSize violate HTML spec and
   break the _syncFontSizePicker visual sync which reads via
   document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn')
   — only the first grid would update its highlight, leaving the second
   stale.  $('settingsFontSize') via getElementById also always returns
   the first match, so the second hidden input never reflected the
   user's choice.

   Removed the Preferences-pane copy.  The Appearance-pane copy is the
   one the PR description describes and is the correct home for it
   (next to Theme and Skin).

2. CHANGELOG trailer said `Closes #830.` but #830 is the session-search
   autocomplete PR — this feature closes #833.  Fixed.

Added two regression tests:
- test_font_size_picker_not_duplicated: asserts each ID appears exactly
  once in index.html.
- test_font_size_picker_lives_in_appearance_pane: asserts the picker
  sits inside settingsPaneAppearance and not any other pane.

Full suite: 1754 passed, 0 failures.

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: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:52:45 -07:00
nesquena-hermes 1239129ae2 fix(models): stale cross-provider model no longer shows as unavailable in picker (closes #829)
* fix(models): stale cross-provider model no longer shows as unavailable in picker

Two bugs allowed an openai/gpt-5.4-mini stale session model to appear as
'(unavailable)' under a custom provider group for users who never configured
OpenAI (#829).

Backend (api/routes.py): _resolve_compatible_session_model() had a blanket
early-return for active_provider in {custom, openrouter} that skipped all
normalization regardless of whether any catalog group could route the model's
prefix. A custom_providers-only user with a stale openai/... session model
was never corrected. Fixed: only skip normalization when the model prefix is
actually routable (matches a catalog group provider_id, or an openrouter
group is present that can route any provider/model).

Frontend (static/ui.js): renderSession() injected a bare <option> (not in
any <optgroup>) for models not found in the dropdown. renderModelDropdown()
rendered bare options without emitting a group heading, so they visually
inherited the last rendered provider heading — making the stale model appear
to belong to the custom provider group. Fixed: silently reset to the first
available model and fire a PATCH to persist the correction instead of
injecting a misleading (unavailable) option.

5 new tests in test_provider_mismatch.py cover:
- stale openai model cleared when custom_providers-only + no default_model
- stale openai model cleared when custom_providers-only + default_model set
- openrouter model preserved when openrouter group present
- custom/ namespace always preserved
- ui.js no longer injects model_unavailable option

* fix(ui): declare modelSel locally in syncTopbar reset path; fix test assertion

- Use const modelSel=$('modelSelect') instead of undeclared sel in the
  stale-model reset branch of syncTopbar() (caught in Opus review)
- Fix test assertion: or → and for model_unavailable key absence check

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 22:20:08 -07:00
nesquena-hermes 880085a09e fix(ui): clear session search on boot + autocomplete=off + pageshow bfcache handler (closes #822)
* fix(ui): clear session search on boot + autocomplete=off — prevents bfcache from restoring stale filter (closes #822)

* fix(ui): add pageshow handler for true bfcache restore case (#822 completion)

The original PR's two fixes cover fresh page loads and hard reloads —
but the bug the issue describes happens on *bfcache restore* (Chrome's
back-forward cache).  The async boot IIFE does NOT re-run when the
browser restores a page from bfcache; the DOM is restored in place,
including any stale #sessionSearch value.  The boot-time clear has no
effect there.

`autocomplete="off"` is a hint that Chrome and others sometimes honour
for bfcache but is not reliable for user-typed values (as opposed to
autofill candidates).

Add a pageshow event listener that checks event.persisted === true and,
on that path only, clears #sessionSearch and re-renders from cache.
Fresh loads skip the listener (persisted=false) and continue to be
handled by the boot IIFE.

Also added tests/test_session_search_bfcache_822.py with 7 tests:
- autocomplete="off" present on the input
- boot-time clear runs before the first renderSessionList
- pageshow listener registered
- handler guards on event.persisted
- handler clears the search field and triggers a re-render

Full suite: 1745 passed, 0 failures.

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: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:11:32 -07:00