Compare commits

...

353 Commits

Author SHA1 Message Date
nesquena-hermes
6c343aff84 v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
nesquena-hermes
7d1aa2e261 v0.50.209: check-for-updates, workspace toggle, HTML preview, provider categories, queue flyout docs (#1042)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat: add manual 'Check for Updates' button in System settings (#785)

Add a 'Check now' button next to the version badge in the System
settings section, allowing users to manually trigger an update check
at any time without waiting for the automatic periodic check.

Changes:
- index.html: add button with spinner and status text inline with version badge
- panels.js: add checkUpdatesNow() calling /api/updates/check?force=1
  with immediate feedback (checking... / up to date / X updates available)
- style.css: style the button block and spinner
- i18n.js: add 5 new keys (settings_check_now, settings_checking,
  settings_up_to_date, settings_updates_available, settings_updates_disabled)
  in all 6 locales (en, ru, es, de, zh, zh-Hant)

* fix: sanitize error message in checkUpdatesNow to avoid exposing paths

Review feedback: strip filesystem paths from error messages and cap
length to prevent internal details leaking into the UI.

* fix: fully sanitize error in update check — never expose raw e.message in UI

Previous partial fix (80cdaee) stripped filesystem paths from e.message but
still displayed the JS exception message to users. Per reviewer feedback and
project convention (NEVER expose raw e.message in UI), replace with:
- A generic user-facing i18n key (settings_update_check_failed) as default
- Fallback to API response body error if available (structured, not raw)
- Full error logged via console.warn for debugging
- Button disable-during-check already confirmed working (try/finally pattern)
- settings_update_check_failed key added in all 6 locales

* fix(#785): align HTML selectors with CSS and add regression tests

- Wrap update button in div#checkUpdatesBlock so CSS selectors apply
- Change button class from sm-btn to btn-tiny (matching stylesheet)
- Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny)
- Move spinner sizing to CSS class .spinner-xs
- Add 4 static tests in test_update_banner_fixes.py:
  checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales

* feat: 'Keep workspace panel open' toggle in Appearance settings (#999)

* feat: categorize providers in setup wizard (#603)

- Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok),
  Ollama, LM Studio to the onboarding quick-setup catalog
- Group providers into 3 categories: Easy start, Open/self-hosted,
  Specialized — rendered as <optgroup> in the provider dropdown
- Generic base_url save logic (requires_base_url + default_base_url)
  instead of hardcoded provider checks
- i18n keys for category labels in en, ru, es, zh, zh-Hant

* ci: re-run tests

* fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644

The test helper _available_models_with_cfg patches cfg in-memory but
get_available_models() calls reload_config() when the config file's
mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0
and _cfg_mtime starts at 0.0, triggering a reload that overwrites the
test's mock with on-disk content.

Fix: freeze _cfg_mtime to the current config file mtime inside the
helper, so reload_config() is not triggered during the test.

* fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests

- gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview
- x-ai: grok-4.20 → grok-3
- deepseek: deepseek-chat-v3-0324 → deepseek-chat
- Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for
  gemini, deepseek, mistral, and x-ai through apply_onboarding_setup

* test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai

* fix(onboarding): correct stale model defaults for specialized providers

Three issues in the new specialized provider catalog (#1027 hold reason):

1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog
   has the 3.1 family. Updated to `gemini-3.1-pro-preview`.
2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`.
   Updated.
3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")`
   which returns []. The catalog in api/config.py is keyed under "google"
   (even though the agent's alias map normalizes google -> gemini).
   Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces
   the actual 5-model list. Also forward-compatible lookup for x-ai
   (xai or x-ai key).

Without these fixes, users picking gemini or x-ai in the wizard would
see no model dropdown and the default_model written to config.yaml
would 404 on first chat.

deepseek default_model bumped from `deepseek-chat` to
`deepseek-chat-v3-0324` to match the test fixture's expectation and
the agent catalog's pinned version.

Added two regression tests:
- test_gemini_model_list_is_populated: pins the catalog-key correctness
- test_specialized_default_models_match_catalog: pins the version
  prefixes (3.x for gemini, 4.x for grok)

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

* feat: inline HTML preview in workspace panel (#779)

Render .html/.htm files as live previews in a sandboxed iframe instead
of showing raw source code. Adds an 'Open in browser' button to open
the file in a new tab.

Changes:
- workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing
  in openFile(), and openInBrowser() function
- index.html: add sandboxed iframe element and 'Open in browser' button
  in preview toolbar (visible only for HTML files)
- i18n.js: add 'open_in_browser' key in all 6 locales

The iframe uses sandbox='allow-scripts' for security. Download button
remains available alongside the new preview.

* docs: document sandbox security tradeoff for HTML preview

Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work.
Added code comment explaining the deliberate sandbox=allow-scripts choice:
scripts are needed for most HTML documents but the iframe is still origin-
isolated and cannot access parent cookies/data.

* fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading

routes.py: add inline_preview param — bypasses Content-Disposition:attachment for
text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe.
workspace.js: add &inline=1 to the iframe src URL.
test: add 5 static regression tests for the inline HTML preview.

* fix(security): CSP sandbox header for inline HTML preview

The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only
applies when HTML is loaded INSIDE that iframe. A user tricked into
opening /api/file/raw?path=evil.html&inline=1 directly in a top-level
tab (e.g. via a chat link) would render the HTML in the WebUI's origin
without any sandbox, giving the page full access to cookies and
localStorage.

Server-side Content-Security-Policy: sandbox allow-scripts mirrors the
iframe sandbox exactly: scripts run, but the document is treated as a
unique opaque origin (no allow-same-origin) and cannot read WebUI
cookies, localStorage, or postMessage to the parent regardless of how
the URL is accessed.

Added test_inline_html_response_sets_csp_sandbox to pin the header.

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

* docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43)

* docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209

The stage commit ed2bd18 listed v0.50.209 as a 4-PR release but the
stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in
without a corresponding CHANGELOG entry. Without this fix, the queue
feature ships silently and the bundled Cloudflare CSP relaxation in
api/helpers.py is also undocumented.

Adds two entries:
- Added: queue flyout (#1040) under v0.50.209
- Changed: CSP allowlist for Cloudflare Access deployments

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

---------

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 14:33:41 -07:00
nesquena-hermes
3ce7844a7a feat(queue): Codex-style message queue flyout above composer (#1040)
Some checks failed
Release & Docker / release (push) Has been cancelled
* chore: apply pending #965 queue flyout patches on local master

Queue flyout implementation (PR #965 — pending merge) applied on top of
upstream v0.50.205. Features:
- Queue card slides up from behind composer (approval-card pattern)
- Lucide icons via li(), CSS class system, no inline SVG dumps
- Drag-to-reorder by _queued_at timestamp (survives re-renders)
- Inline contenteditable edit with focus guard and blur-commit
- Combine preserves first item files, merge immediate (no 200ms race)
- Files/model compact badges per item
- Hide/expand via header chevron + composer pill + titlebar chip
- All 3 expand paths sync correctly
- border-bottom CSS order fixed, fingerprint improved, _dragTs guards

CF CSP domains also applied (deployment-specific, not in upstream PR).

* fix(queue): harden merge closure, toggleQueue sid, and drain flash

- mergeBtn _doMerge now reads live queue (_getSessionQueue) instead of stale closure q
- toggleQueue reads activeSid from S.session at call time, not captured param
- updateQueueBadge defers chips.innerHTML='' by 360ms so slide-out transition completes before content clears

* style(queue): contain:paint on inner, pill fade-in animation

* feat(queue): pill outside composer, compact collapsed state matching card width

- Move #queuePill out of .composer-box to between .composer-flyout and .composer-box
- Pill styled as compact queue-card-inner (same border, radius:14px 14px 0 0, no border-bottom)
- Pill width matches card inner: max-width:calc(var(--msg-max)-40px), centered
- Pill stays visible until user re-expands or queue drains (updateQueueBadge no longer
  hides pill when card is manually collapsed)
- Remove all queue-active/queue-pill-active composer modifications — composer untouched
- Fix: mergeBtn reads live queue not stale closure
- Fix: toggleQueue uses S.session.session_id at call time not captured param
- Fix: chips.innerHTML deferred 360ms on drain to avoid empty-card flash

* fix(queue): collapsed state persists + cross-session DOM isolation

- Add _queueCollapsed[sid] flag: set by hideBtn, cleared by pill expand / queue drain
- _renderQueueChips respects flag — no longer reopens card when new message queued while collapsed
- updateQueueBadge else-branch: DOM mutations now gated on sid===active session
- _syncQueueTitlebar only fires for active session in else-branch
- Fixes Opus/Codex-identified bugs: pill auto-reopen and cross-session DOM corruption

* fix(queue): proper pill wrapper matching queue-card structure

- Add .queue-pill-outer div wrapper (max-width:var(--msg-max); padding:0 20px)
  identical to .queue-card outer — positions pill button at exact card-inner width
- .queue-pill button fills slot with width:100%
- Removes hardcoded 740px — width is derived correctly from the same CSS variables
  the card uses, scales with --msg-max across all viewports
- JS toggles .show on pillOuter (parentElement), not on pill button directly

---------

Co-authored-by: Basit Mustafa <basit.mustafa@gmail.com>
2026-04-25 14:21:50 -07:00
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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
nesquena-hermes
44a6587e78 docs(architecture): document workspace path trust levels (#993)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 13:22:28 -07:00
nesquena-hermes
0a6f15d8d9 chore: v0.50.205 CHANGELOG
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 13:04:44 -07:00
nesquena-hermes
2800ebdcff fix(workspace): allow adding external paths not under home directory (#991)
The workspace add endpoint used resolve_trusted_workspace() which blocks any path
outside the user's home directory, the saved workspace list, or BOOT_DEFAULT_WORKSPACE.
This created a circular dependency: to add /mnt/d/Projects you need it in the saved
list, but to get it in the list you need to add it.

Fix: introduce validate_workspace_to_add() used by /api/workspaces/add, which only
blocks non-existent paths, non-directories, and known system roots. The stricter
resolve_trusted_workspace() is still used for actual file operations within a workspace.

Fixes #953.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 13:04:36 -07:00
nesquena-hermes
3c457d178d chore: v0.50.204 CHANGELOG
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 12:54:13 -07:00
nesquena-hermes
c0019723d1 fix(docker): use /home/hermes/.hermes for HERMES_HOME in compose files (#989)
Fixes container crash on startup (#967). The hermes-agent image drops
privileges to a 'hermes' user via gosu; /root is mode 700 so mkdir
fails under /root/.hermes. Changed to /home/hermes/.hermes throughout.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 12:54:05 -07:00
nesquena-hermes
34329ad231 chore: v0.50.203 CHANGELOG (#964)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 12:34:05 -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
nesquena-hermes
619646159c chore: v0.50.202 CHANGELOG (#972)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 12:33:25 -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
32276c81d1 chore: v0.50.201 CHANGELOG
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:58:18 -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
nesquena-hermes
8ce83b637c chore: v0.50.200 CHANGELOG (#963)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:49:23 -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
nesquena-hermes
5663fb147b chore: v0.50.199 CHANGELOG (#966)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:44:56 -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
da131b842d chore: v0.50.198 CHANGELOG (hotfix)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:41:41 -07:00
nesquena-hermes
ef72384217 fix: harden _accepts_gzip + update stale test assertions post-#959 (#981)
Fixes introduced when absorbing PR #959 (fast conversation switching):
- _accepts_gzip() now uses getattr() to tolerate _FakeHandler and any
  synthesised handler that lacks a .headers attribute (fixes 2 test failures
  in test_sprint46.py)
- test_issue401: updated assertion to accept both minified and reformatted
  forms of the tool_calls fallback guard (PR reformatted the code)
- test_regressions: updated activeStreamId assertion — PR refactored
  data.session references to S.session for direct state access

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:41:17 -07:00
nesquena-hermes
116a510ed3 i18n: add complete Traditional Chinese (zh-Hant) translations (#954)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
nesquena-hermes
ed24010e10 chore: v0.50.197 CHANGELOG (#954)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:36:13 -07:00
nesquena-hermes
23b7c63198 chore: v0.50.196 CHANGELOG (#959)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:35:23 -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
nesquena-hermes
2d5c4b71cc chore: v0.50.195 CHANGELOG (#962)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:21:50 -07:00
Basit Mustafa
4a882bec66 fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.

The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:

- _load_sessions(): reads .sessions.json on module import, prunes expired
  entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
  mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed

Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 11:21:41 -07:00
nesquena-hermes
f48b157a8f chore: v0.50.194 CHANGELOG (#960)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:04:42 -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
nesquena-hermes
c06ec43f17 chore: v0.50.193 CHANGELOG (#958)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:04:26 -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
nesquena-hermes
70de09290c chore: v0.50.192 CHANGELOG (#951)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:04:09 -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
nesquena-hermes
d339200b5b chore: v0.50.191 CHANGELOG (#948)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:03:52 -07:00
starship-s
0a91e3cb02 fix: identify WebUI sessions as webui platform (#948)
* fix: use webui platform for webui sessions

* test: harden WebUI platform hint regression coverage
2026-04-24 11:03:42 -07:00
nesquena-hermes
cb41075bd2 chore: v0.50.190 CHANGELOG (.venv #949)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 10:45:33 -07:00
xingyue
91703e3e54 fix(config): add .venv discovery paths in _discover_python (#949) 2026-04-24 10:45:23 -07:00
nesquena-hermes
396537c624 chore: v0.50.189 CHANGELOG (#961 csp)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 10:45:09 -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
nesquena-hermes
27e69c404a chore: v0.50.189 CHANGELOG (csp #961)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 10:44:34 -07:00
nesquena-hermes
dbc9c910a8 chore: v0.50.188 CHANGELOG (btw fix #950)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 10:44:10 -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
e0257d81d5 chore: v0.50.187 CHANGELOG entry for breakpoint fix (#956)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 09:13:35 -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
nesquena-hermes
885f1fa349 chore: v0.50.186 CHANGELOG entry for three-column layout (#899)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 09:06:50 -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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
ce9aec1640 chore: v0.50.184 release notes (#936)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 19:38:48 -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
06bedc8e23 Merge pull request #932 from nesquena/pr-929-review
Some checks failed
Release & Docker / release (push) Has been cancelled
feat(commands): /background, /btw slash commands + undo button + reasoning chip
2026-04-23 18:33:50 -07:00
Nathan Esquenazi
63b0207604 fix(background): wire completion hook + keep running tasks in tracker
The /background feature was fundamentally non-functional as shipped —
two coupled bugs kept results from ever reaching the user:

1. complete_background() was defined but NEVER called.  The
   _handle_background thread ran _run_agent_streaming and then exited;
   no hook signalled the task tracker that the work was done.  Every
   background task stayed in status="running" forever and
   get_results() (which filters to done-only) always returned [].

2. get_results() called _BACKGROUND_TASKS.pop(parent_sid, []) which
   removed the ENTIRE list — including tasks still in flight.  Even if
   bug #1 were fixed, the first frontend poll during a long-running
   task would drop the task from the tracker, and
   complete_background()'s loop would iterate over an empty list when
   the worker eventually finished — the result would still be lost.

Fix:

- api/background.py::get_results now retains running tasks in the
  dict; only done ones are popped and returned.
- api/routes.py::_handle_background wraps _run_agent_streaming in an
  inline worker (_run_bg_and_notify) that, after streaming completes,
  reloads the hidden bg session, extracts the last non-error assistant
  message, and calls complete_background(parent_sid, task_id, answer).
  Worker also best-effort unlinks the hidden bg session file so
  SESSION_DIR doesn't accumulate debris.
- Exception safety: any failure in _run_agent_streaming or the
  post-processing path still calls complete_background with a fallback
  sentinel so the frontend's polling loop doesn't hang forever.

Added 5 regression tests in tests/test_background_tasks.py:
- running tasks survive get_results polls
- done tasks are returned and removed
- poll → complete → poll round-trip surfaces the answer (this is the
  original bug's reproduction path)
- empty parent is cleaned up
- static check: _handle_background's worker calls complete_background
  and uses Session.load to extract the answer

Full suite: 2023 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:32:47 +00: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
57222c70e7 Merge pull request #931 from nesquena/pr-925-review
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(streaming): respect auxiliary.title_generation config for session titles
2026-04-23 18:22:44 -07:00
nesquena-hermes
14a1924796 fix(streaming): respect auxiliary.title_generation config for session titles
- _aux_title_configured(): returns True when provider/model/base_url is set
- _aux_title_timeout(): reads configured timeout, falls back to 15.0s default
- _generate_llm_session_title_via_aux: use_agent_model kwarg preserves old behavior
- Missing llm_invalid_aux fallback now triggers agent-model retry
- 23 new tests in tests/test_title_aux_routing.py — all pass

Co-authored-by: starship-s <starship-s@users.noreply.github.com>
2026-04-24 01:07:02 +00:00
nesquena-hermes
36da37ff13 Merge pull request #930 from nesquena/feat/vendor-smd-0.2.15
Some checks failed
Release & Docker / release (push) Has been cancelled
chore: vendor streaming-markdown@0.2.15, remove CDN dependency
2026-04-23 18:06:26 -07: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
nesquena-hermes
ff970ec844 Merge pull request #923 from nesquena/feat/917-streaming-markdown
Some checks failed
Release & Docker / release (push) Has been cancelled
Merging feat/917-streaming-markdown. 2065 tests pass. APPROVED by @nesquena. Pre-existing QA harness failure on master confirmed (not a regression).
2026-04-23 17:43:40 -07: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
a3647570fb fix: persist onboarding_completed for CLI-configured users on first chat_ready (#922)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: persist onboarding_completed for CLI-configured users on first chat_ready (v0.50.179, #921)

Co-authored-by: bsgdigital

* fix(onboarding): don't 500 the status endpoint if save_settings fails

The #921 persist call `save_settings({"onboarding_completed": True})` in
get_onboarding_status() raises if the settings.json write fails
(read-only filesystem, disk full, permission error). That turns every
/api/onboarding/status call into a 500 until the disk is writable,
which is much worse UX than losing the persistence-across-restart guard.

Wrapped in try/except so persistence becomes best-effort. The function
still sets settings["onboarding_completed"] = True in memory on success,
and `completed` reflects `config_auto_completed` on this request either
way, so the user sees the right state even when the write fails — only
the next-restart protection degrades.

Added regression test that patches save_settings to raise OSError and
asserts the endpoint still returns completed=True without raising.

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:46:02 -07:00
nesquena-hermes
1011918d50 feat: add PWA support (manifest, service worker, install prompt) (#920)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 14:41:06 -07:00
nesquena-hermes
5b923a9502 fix: harden session persistence and per-session lock handling during streaming (v0.50.175, #910) (#910)
Co-authored-by: starship-s

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 14:25:43 -07:00
nesquena-hermes
5082f426f2 fix: correct interleaved streaming order (Text → Thinking → Tool → Text) (#913)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
9dd6e3f338 fix(cancel): preserve partial streamed response on Stop Generation (#893) (#902)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(cancel): preserve partial streamed response on Stop Generation (#893)

* docs(cancel): fix misleading comment — partial message is NOT _error=True

The outer comment block claimed `_error=True so _sanitize_messages_for_api()
strips it from future conversation history`, but the actual append call
sets only `_partial=True` (correctly matching the inner comment six lines
below and the PR description). Updated the outer comment to match reality
so a future reader doesn't try to "fix" the code to match the wrong comment.

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 11:16:59 -07:00
nesquena-hermes
4089972b09 fix(models): preserve @nous: prefix in settings + fix cross-namespace 404 for Nous (#895 #894) (#901)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
cd01e4d5ba feat(models): live-first model fetching for all OpenAI-compat providers (#892)
* feat(models): live-first model fetching for all OpenAI-compat providers (#871)

The WebUI model picker relied on hardcoded _PROVIDER_MODELS as primary
source for providers like zai, minimax, mistralai, xai, openai-codex,
deepseek, and gemini. These lists go stale — new models don't appear
until someone manually updates the dict.

Add an OpenAI-compat /v1/models fetch fallback in _handle_live_models()
that fires when provider_model_ids() is unavailable or returns []. The
resolution chain is now:

  1. hermes_cli.provider_model_ids() (agent's live fetch)
  2. Custom providers from config.yaml
  3. Direct /v1/models fetch for known OpenAI-compat endpoints
  4. Static _PROVIDER_MODELS as last-resort offline fallback

Covers: zai, minimax, mistralai, xai, openai-codex, deepseek, gemini.

Uses urllib (stdlib) — no new dependencies. Static lists remain as
offline fallback so the UI always shows something.

Closes #871

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(models): address review feedback on live fetch (#892)

Five changes from nesquena-hermes review:

1. Move _OPENAI_COMPAT_ENDPOINTS to module level — avoid dict
   reconstruction per request
2. Document urllib blocking behavior — 8s timeout acceptable because
   server is threaded and frontend enriches in background
3. Add TODO comment for TTL-based caching follow-up
4. Remove openai-codex from endpoint map — same endpoint as base
   openai provider, already covered by provider_model_ids()
5. Restrict API key lookup to provider-scoped and model.api_key only
   — remove top-level api_key fallback to prevent cross-provider
   key leakage

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 09:45:46 -07:00
Pavol Biely
96c97c5e0e fix: remove hardcoded chinese title heuristics (#887)
* fix: remove hardcoded chinese title heuristics

* fix: use english placeholder for non-latin fallback titles
2026-04-23 09:45:34 -07:00
Joe Maples
ae7be6deba fix(docker): Install all dependencies for agent (#897) 2026-04-23 09:45:28 -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)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
666d385c03 fix: Nous static models use @nous: prefix — v0.50.164 (#885)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: Nous static models use @nous: prefix — v0.50.164 (#885)

Follow-up to #854 / PR #870. The previous fix made Nous static IDs
slash-prefixed and added a portal-guard branch to resolve_model_provider().
This tightens the static list to use the explicit @nous: prefix, matching
the format of live-fetched models after ui.js's _fetchLiveModels() portal-
prefix step.

The @provider:model branch in resolve_model_provider() is more explicit and
reliable than the portal-guard fallback. Both static and live-fetched paths
now converge on the same resolver output — and as a side effect, the dedup
check in _fetchLiveModels() now correctly identifies static entries as already
present, eliminating duplicate entries in the dropdown for Nous users.

Verified: all 29 Nous models in the browser dropdown carry @nous: prefix,
routing confirmed correct via resolve_model_provider() for all 4 static IDs,
1941 tests passing.

Closes #854.
2026-04-22 22:56:21 -07:00
nesquena-hermes
d39d30a213 fix: correct message ordering after task cancellation — v0.50.163 (#883)
Some checks failed
Release & Docker / release (push) Has been cancelled
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)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
0f1b232c12 fix(ci): eliminate test_set_key flakiness — v0.50.161
Some checks failed
Release & Docker / release (push) Has been cancelled
Root cause: test_profile_env_isolation.py and test_profile_path_security.py called sys.modules.pop() without restoring, poisoning subsequent tests. Fix: monkeypatch.delitem so pytest auto-restores. Also holds _ENV_LOCK for full I/O cycle in _write_env_file and creates .env at 0600 via os.open. Reviewed by Opus (no independent review needed — test/providers fix only).
2026-04-23 02:09:37 +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)
Some checks failed
Release & Docker / release (push) Has been cancelled
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)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
bergeouss
a72208eaf6 fix(docker): improve two-container agent path discovery and docs — v0.50.158 (PR #873 by @bergeouss, closes #858)
Some checks failed
Release & Docker / release (push) Has been cancelled
docker_init.bash now checks /opt/hermes as a fallback alongside the primary path. Warning updated with concrete mount guidance. Volume type notes added to compose files and README.
2026-04-22 23:35:09 +00:00
nesquena-hermes
0a75b3f1d3 fix: Nous portal model IDs + portal provider routing guard — v0.50.157 (closes #854)
Two bugs fixed: (1) _PROVIDER_MODELS["nous"] updated to slash-prefixed IDs that Nous API expects. (2) resolve_model_provider() now routes portal provider models through the portal (not OpenRouter) and preserves the full slash-prefixed model ID. 10 regression tests.
2026-04-22 23:05:27 +00:00
Joe Maples
1a98f75005 fix(docker): add openssh-client to Docker image for SSH terminal backend — v0.50.157 (PR #868 by @frap129)
Some checks failed
Release & Docker / release (push) Has been cancelled
Adds openssh-client to apt-get install block so Docker users running the SSH terminal backend can connect to remote agents. Closes #868.
2026-04-22 22:39:41 +00:00
nesquena-hermes
095dbfd641 docs: update ROADMAP, SPRINTS, and BUGS to v0.50.156 — 1903 tests
Update sprint history table in ROADMAP.md through v0.50.156, fix test count header, add Known Limitations section to BUGS.md, update SPRINTS.md header. Reviewed by Opus — factually accurate, table column alignment fixed.
2026-04-22 21:14:08 +00:00
nesquena-hermes
3a63fe479e fix(security): gate auto-install behind HERMES_WEBUI_AUTO_INSTALL=1 — v0.50.156
Some checks failed
Release & Docker / release (push) Has been cancelled
Breaking: auto_install_agent_deps() is now disabled by default. Set HERMES_WEBUI_AUTO_INSTALL=1 to re-enable. New _trusted_agent_dir() checks ownership and permission bits. Addresses #842 by @tomaioo.
2026-04-22 20:49:28 +00:00
nesquena-hermes
96cb880a12 fix: Honcho per-session uses stable session ID across WebUI turns — v0.50.155 (closes #855)
Pass gateway_session_key=session_id to AIAgent from streaming.py so Honcho per-session strategy pins to stable WebUI session ID rather than creating a new Honcho session each turn.
2026-04-22 20:48:52 +00:00
nesquena-hermes
e151665131 release: v0.50.154 — image_generate, auto-title, portal routing, thinking card fixes
Bumps README test count to 1898. Release tag for v0.50.151-154 bug fixes.
2026-04-22 20:47:52 +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
nesquena-hermes
d8e1f37e2b release: v0.50.150 — session index, read-path, profile-switching fixes
Some checks failed
Release & Docker / release (push) Has been cancelled
Bundles three bug fixes (PRs #847, #848, #849) and updates README test count to 1858.

- v0.50.148: prune stale _index.json ghost rows after session-id rotation (closes #846)
- v0.50.149: side-effect-free GET /api/session model resolution (closes #845)
- v0.50.150: profile switching cookie persist + syncTopbar fix + active indicator state
2026-04-22 17:09:35 +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
Frank Song
418d77443c fix: keep GET /api/session side-effect free for stale models — v0.50.149 (PR #848 by @franksong2702)
Replace _normalize_session_model_in_place() on the GET /api/session read path with a read-only _resolve_effective_session_model_for_display() that returns the effective display model without writing it back to disk or the session index.

Closes #845.

Tests: 1856 passing.
2026-04-22 16:26:48 +00:00
Frank Song
13dbd818c9 fix: prune stale session index entries after session-id rotation — v0.50.148 (PR #847 by @franksong2702)
Prune ghost _index.json rows whose backing session file no longer exists, on both incremental index writes and all_sessions() reads. Fixes duplicate session entries after session-id rotation (e.g. context compression). Also pre-snapshots in_memory_ids under a single LOCK acquisition in all_sessions() rather than one per row.

Closes #846.

Review additions: optimised lock pattern in all_sessions() (one LOCK acquisition instead of N). Tests: 1856 passing.
2026-04-22 16:26:38 +00:00
nesquena-hermes
85434dd03c fix(appearance): font size setting now visibly scales UI text (closes #843)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
nesquena-hermes
d4a3adb7b1 fix(sessions): surface gateway SSE failures and add polling fallback (#828)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(sessions): surface gateway SSE failures and add polling fallback

- add a JSON probe mode for the gateway SSE endpoint
- detect watcher-unavailable 503s from the browser
- fall back to periodic session refresh with a toast
- add probe payload tests and endpoint coverage

Fixes #635

* fix(sessions): surface gateway SSE failures and add polling fallback (#826)

Absorbed from PR #826 by @cloudyun888 (fixes #635).

When the gateway watcher thread is not running, the browser now shows a
toast notification and falls back to 30-second periodic polling for session
sync. Previously the SSE failure was completely silent with no user feedback.

Changes from original PR:
- Deleted misplaced test_gateway_sse_probe_unit.py (was at repo root, not
  discovered by `pytest tests/`); unit tests moved into tests/test_gateway_sync.py
- _gateway_sse_probe_payload now checks watcher._thread.is_alive() rather
  than just watcher is not None — a watcher instance with a dead poll thread
  now correctly reports unavailable and activates the polling fallback
- probeGatewaySSEStatus catch(e) now starts the polling fallback on network
  error rather than silently swallowing the failure
- Added 5 unit tests covering all watcher-alive/dead/missing/disabled branches

Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@users.noreply.github.com>

* cleanup(gateway): public is_alive() + dedup probe/live watcher-alive check + changelog

Three small cleanups on top of @cloudyun888's PR #826 absorption:

1. Add GatewayWatcher.is_alive() public accessor so routes.py doesn't
   reach into the private _thread attribute.  The existing private-
   attribute check stays as a defensive fallback for any older in-
   memory instance or test double that doesn't implement the full API.

2. Dedupe the watcher_alive computation in _handle_gateway_sse_stream:
   the live-SSE path now calls _gateway_sse_probe_payload(...) and reads
   its watcher_running field instead of re-deriving the same logic
   inline.  Keeps probe and SSE in sync automatically.

3. CHANGELOG trailer was (#826, fixes #635, @cloudyun888) — this PR is
   #828, so updated to (#828, absorbs PR #826 by @cloudyun888, fixes
   #635) matching the repo convention for absorbed PRs (see #805).

Added two regression tests:
- test_gateway_watcher_is_alive_public_method — covers the three
  lifecycle states (before start, while running, after stop).
- test_probe_payload_prefers_public_is_alive — asserts the probe
  uses watcher.is_alive() rather than poking _thread when the
  public method exists.

Full suite: 1735 passed, 0 new failures.

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

---------

Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:18:55 -07:00
nesquena-hermes
3daf2427f7 docs(testing): update automated test count to 1777 (#827)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 20:32:24 -07:00
nesquena-hermes
d41d05ea36 fix(workspace): _profileDefaultWorkspace persists after newSession() (#823)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #823.

Separates two conflated semantics in S._profileDefaultWorkspace:
- Persistent blank-page default (set by boot/settings, never nulled)
- Profile-switch one-shot (now S._profileSwitchWorkspace, consumed by newSession())

newSession() priority: switchWs → current session → _profileDefaultWorkspace.
switchToWorkspace() clears _profileSwitchWorkspace on explicit switch.

9 new tests. 1777/1777 suite. Browser-verified.
2026-04-21 19:14:31 -07:00
nesquena-hermes
859602340e fix: streaming race conditions (#631) + blank-page workspace binding (#804)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #631. Closes #804.

Bug A (thinking card below answer / double render / stuck cursor): trailing rAF after 'done'
inserted a duplicate live-turn wrapper into already-settled DOM. Fixed via _streamFinalized flag
+ cancelAnimationFrame in all terminal handlers (done/apperror/cancel/_handleStreamError) +
_scheduleRender guard. All three reported symptoms were the same root cause.

Bug B (accumulator reset): original fix reset assistantText/reasoningText inside _wireSSE on reconnect.
Reverted — server uses one-shot queue.Queue(), no replay on reconnect, reset would wipe valid
pre-drop content causing data loss. Bug A fix alone resolves all symptoms.

#804 (blank page workspace): syncWorkspaceDisplays uses S._profileDefaultWorkspace as fallback;
workspace chip enabled when hasWorkspace (not hasSession); promptNewFile/promptNewFolder/
switchToWorkspace/promptWorkspacePath auto-create session on blank page; boot.js hydrates
_profileDefaultWorkspace from /api/settings before any session exists.

Opus max-effort review + Nathan independent review + full browser QA. 1765/1765 tests.
2026-04-21 18:47:40 -07:00
nesquena-hermes
c3807482be fix(tests): pin _cfg_mtime=0.0 in except so CI reload_config() guard works
Some checks failed
Release & Docker / release (push) Has been cancelled
Root cause of persistent CI failure on `test_custom_endpoint_uses_model_config_api_key_for_model_discovery`:

The helper `_available_models_with_full_cfg` and the failing test itself both do:
```python
try:
    _cfg._cfg_mtime = stat().st_mtime
except Exception:
    pass  # ← leaves _cfg_mtime at stale value from a prior test
```

On CI (no `~/.hermes/config.yaml`), `stat()` raises `OSError`. `get_available_models()` then sees `_current_mtime=0.0 != _cfg_mtime=<stale from prior test>`, calls `reload_config()`, overwrites the test's in-memory `cfg`, `api_key` disappears, `urlopen` is never called, `captured['auth']` raises `KeyError`.

**Fix:** The `except` clause now sets `_cfg._cfg_mtime = 0.0` (matching what `get_available_models()` will see when `stat()` also fails), so the reload guard becomes a no-op and the test's `cfg` mutation survives. Same pattern already used correctly in `test_custom_provider_display_name.py` and `test_ttl_cache.py`.

Verified with `HERMES_CONFIG_PATH=/tmp/nonexistent` to reproduce the CI no-config condition locally. 1747/1747 full suite.
2026-04-21 17:49:10 -07:00
nesquena-hermes
2d8bccdd96 fix(tests): add autouse cache-isolation fixture to get_available_models test files
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes the CI failure introduced by #817: test_model_resolver::test_custom_endpoint_uses_model_config_api_key_for_model_discovery was failing with KeyError: 'auth' due to the 60s TTL cache in get_available_models() being populated by test_byok_model_dropdown.py tests that ran earlier. Added autouse _isolate_models_cache fixture to 5 test files. Full suite 1747/1747, QA harness green.
2026-04-21 17:41:05 -07:00
nesquena-hermes
8f1f582caf fix: BYOK/custom provider models missing from WebUI model dropdown (#815)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #815.

Three root causes fixed:

1. Provider aliases (z.ai/x.ai/google/grok/claude/aws-bedrock/dashscope/~25 more) not
   normalized before _PROVIDER_MODELS lookup — provider fell to empty else-branch while
   TUI worked (it normalizes at startup). Fixed via _resolve_provider_alias() + inlined
   _PROVIDER_ALIASES table in api/config.py.

2. Silent ImportError in original normalization: 'from hermes_cli.models import
   _PROVIDER_ALIASES' inside try/except silently failed without hermes-agent on sys.path
   (CI, minimal installs). The inlined table fixes this — normalization now works
   regardless of whether hermes-agent is installed.

3. /api/models/live?provider=custom now falls back to custom_providers entries from
   config.yaml when provider_model_ids() returns empty.

Also: provider_id on every group in /api/models response for deterministic JS optgroup
matching (no substring false positives). 17 targeted tests, 1725/1725 full suite.
2026-04-21 17:24:54 -07:00
nesquena-hermes
a4d59b9e6c fix: update banner — conflict recovery path + server self-restart after update (#816)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: update banner conflict recovery + server self-restart after update (#813 #814)

* fix(update): restart must wait for in-flight update + reset force button on retry

Two defects in the update banner flow found during review of PR #816:

1. Two-target race (webui + agent sequential)
   The client posts targets sequentially: webui succeeds and schedules
   a restart timer (2 s delay); client then posts agent; server begins
   agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull,
   killing the agent update and closing the client connection. User
   sees "Update failed (agent): Failed to fetch" even though webui did
   update, and the agent repo is in an unknown partial state.

   Fix: _schedule_restart() now blocks on _apply_lock before calling
   os.execv. If a second update is in flight when the timer fires, the
   restart thread waits until it completes. If nothing is in flight the
   lock acquire is instant, so no-op updates still restart immediately.

2. Stale force-update button across retries
   _showUpdateError sets btnForceUpdate to display:inline-block when
   res.conflict / res.diverged. Nothing resets it on the next retry,
   so a subsequent non-conflict error (e.g. network) leaves the stale
   force button visible pointing at the previous target.

   Fix: applyUpdates() now hides the force button and clears its
   data-target at the start of each attempt.

Tests:
- test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a
  helper thread, verifies execv is delayed until the lock is released.
- test_schedule_restart_still_fires_when_no_update_in_flight: sanity
  check that the common path still works with no contention.
- test_apply_updates_resets_force_button_at_start: regression guard
  that the reset appears before the update loop begins.

Full suite: 1683 passed, 0 failures.

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

* fix(update): hold _apply_lock through execv + fix banner error layout

Two fixes from Opus review:

1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern
   acquired _apply_lock, released it, then called os.execv — leaving a brief
   window where a new update could start between release and execv. Fixed by
   moving os.execv inside the 'with _apply_lock:' block so the process is
   replaced while still holding the lock; no new update can acquire it.

2. Banner CSS layout (static/index.html): #updateError was a direct flex child
   of .update-banner (display:flex row), so long error messages sat inline
   between #updateMsg and the buttons instead of below the message.
   Wrapped #updateMsg + #updateError in a flex-column container so errors
   stack vertically under the status line.

* docs: add v0.50.134 CHANGELOG entry

---------

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 17:10:41 -07:00
nesquena-hermes
811424a87b feat(reasoning): full /reasoning CLI parity — show|hide + effort levels via config.yaml (#812)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #461

Adds full /reasoning CLI parity to the WebUI slash command system:

- /reasoning show|on → window._showThinking = true; writes display.show_reasoning to config.yaml (same key as CLI); mirrors to settings.json for boot.js
- /reasoning hide|off → same in reverse; re-renders immediately
- /reasoning none|minimal|low|medium|high|xhigh → POST /api/reasoning → writes agent.reasoning_effort to config.yaml; takes effect next turn (matching CLI semantics)
- /reasoning (no args) → GET /api/reasoning → live status toast from config.yaml
- Autocomplete shows all 8 options: show|hide|none|minimal|low|medium|high|xhigh
- Profile-isolated: _get_config_path() is thread-local so per-profile settings never bleed across
- Boot hydration: window._showThinking initialised from settings.json show_thinking on page load
- Inspect.signature guard in streaming.py so older hermes-agent builds don't TypeError

28 new tests, 1708/1708 total passing. Full browser QA on port 8789 with isolated state. CLI/config.yaml sync verified with hermes_constants.parse_reasoning_effort().
2026-04-21 15:26:52 -07:00
nesquena-hermes
f6e1612c7e fix: periodic session checkpoint during streaming — v0.50.132 (#810)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #765. Supersedes #809 (@bergeouss). Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
2026-04-21 12:07:44 -07:00
nesquena-hermes
081c4208d9 docs: fix CHANGELOG word count for v0.50.131 (#808)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 17:42:17 +00:00
nesquena-hermes
e05fc4e0e4 fix(ui): workspace pane now respects app theme (#807)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #786. Seven hardcoded dark-mode rgba values replaced with theme-aware CSS vars.
2026-04-21 17:36:33 +00:00
nesquena-hermes
312a493a72 fix(sessions): new sessions appear immediately in sidebar (#806)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #789 Bug A. 60-second exemption in all_sessions() filter.
2026-04-21 17:08:52 +00:00
nesquena-hermes
3246b263d9 fix(profiles): complete profile isolation via cookie + thread-local (#805)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes the gap left by #800. Full isolation via hermes_profile cookie + TLS.
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
2026-04-21 17:04:11 +00:00
nesquena-hermes
bbc917a5c6 fix(renderer): stop &quot; mangling inside code blocks (#801)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #801.

Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com>
2026-04-21 16:26:51 +00:00
nesquena-hermes
cbb4ba3f28 fix(profiles): profile isolation — new_session uses per-request profile, not process global (#800)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes the multi-client profile isolation bug (#798).

- get_hermes_home_for_profile(): pure path resolver, validates name against
  _PROFILE_ID_RE (rejects path traversal), never mutates os.environ or globals
- new_session() accepts explicit profile= param from POST body (S.activeProfile),
  short-circuits the process-level _active_profile global
- streaming handler resolves HERMES_HOME from s.profile instead of the global
- sessions.js sends profile: S.activeProfile in every new-session POST

10 tests in tests/test_issue798.py including concurrency and traversal coverage.

Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-04-21 16:16:51 +00:00
nesquena-hermes
d527629281 docs: add CHANGELOG entry for v0.50.126 (#797) (#799)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 15:42:00 +00:00
Dave Brown
77ab63361f fix(onboarding): recognize credential_pool OAuth auth for openai-codex (#797)
fix(onboarding): recognize credential_pool OAuth auth for openai-codex (#797)

The onboarding readiness check in `api/onboarding.py` only looked at the legacy
`providers[provider]` key in `auth.json`. Hermes runtime resolves OAuth tokens from
`credential_pool[provider]` (device-code / OAuth flows), so WebUI could report "not ready"
while the runtime chatted successfully. The check now covers both storage locations with
a fail-closed helper. Adds three regression tests.

Reported in #796, fixed by @davidsben.

Co-authored-by: davidsben <davidsben@users.noreply.github.com>
2026-04-21 15:41:34 +00:00
nesquena-hermes
3f484aec33 fix: add --chown to Dockerfile COPY so RUN can write api/_version.py (#793)
The v0.50.124 Docker build failed with:
  cannot create /apptoo/api/_version.py: Permission denied

Root cause: 'USER hermeswebuitoo' is set before 'COPY . /apptoo', but
COPY without --chown creates files owned by root. The subsequent RUN
step (which writes api/_version.py) runs as hermeswebuitoo and has no
write permission to the root-owned api/ directory.

Fix: COPY --chown=hermeswebuitoo:hermeswebuitoo so the unprivileged user
owns the app files and can write _version.py at build time.

Regression from #790.

Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-20 21:03:41 -07:00
nesquena-hermes
49ff8b3185 fix: bootstrap.py loads REPO_ROOT/.env so direct invocation matches start.sh (#730) (#791)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: bootstrap.py loads REPO_ROOT/.env so direct invocation matches start.sh

When users run 'python3 bootstrap.py' directly (the primary documented
entry point in README), HERMES_WEBUI_HOST, HERMES_WEBUI_PORT and other
.env settings were silently ignored because the shell-level 'source .env'
in start.sh was never executed.

Add _load_repo_dotenv() in bootstrap.py that reads REPO_ROOT/.env into
os.environ before DEFAULT_HOST / DEFAULT_PORT are evaluated at module
level. Uses unconditional assignment matching 'set -a; source .env'
shell semantics. Only loads the repo .env (bootstrap config) — not
~/.hermes/.env, which the server still loads independently at startup
for provider credentials.

Reported in #730 by @leap233 who had HERMES_WEBUI_HOST=0.0.0.0 and
HERMES_WEBUI_PORT=18787 in the webui .env; running bootstrap.py directly
caused the server to ignore both settings.

Tests: 15 new tests in tests/test_bootstrap_dotenv.py covering the
full loader (key=value, comments, blank lines, quoted values, no-file,
unreadable-file, overwrite semantics, values with = signs) and structural
assertions that _load_repo_dotenv() is called before DEFAULT_HOST/PORT.
1613 tests total.

* fix: address review feedback on PR #791

- bootstrap.py: document overwrite semantics and 'export' note in docstring
- bootstrap.py: handle 'export FOO=bar' prefix (strip before splitting on =)
- bootstrap.py: print warning to stderr on .env parse failure (not silent swallow)
- bootstrap.py: add side-effect comment at _load_repo_dotenv() call site
- CHANGELOG.md: restore v0.50.124 and v0.50.123 headers (were merged into
  v0.50.125 section, making three consecutive ### Fixed blocks with no ## header
  between them)
- tests: fix test_noop_when_dotenv_unreadable to assert warning is emitted
- tests: tighten test_does_not_set_empty_values with concrete assertion
- tests: add test_export_prefix_stripped
- tests: remove dead _import_bootstrap_with_env() helper (never called)
1614 tests total

---------

Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-20 20:55:53 -07:00
nesquena-hermes
38e215e8f8 fix: dynamic version badge — read from git tag, never hardcoded (#790)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: dynamic version badge — read from git tag, never hardcoded

The settings panel showed v0.50.87 and the HTTP Server: header said
HermesWebUI/0.50.38 — both hardcoded strings that drift further behind
with every release because there was no mechanism to keep them in sync.

Changes:
- api/updates.py: add _run_git() (moved before _detect_webui_version),
  _detect_webui_version(), and WEBUI_VERSION module constant resolved
  once at import time via 'git describe --tags --always --dirty'.
  Fallback chain: git → api/_version.py → 'unknown'.
- api/routes.py: inject webui_version into GET /api/settings response
  so the frontend can read it without a separate API call.
- static/panels.js: loadSettingsPanel() populates .settings-version-badge
  from settings.webui_version — one line after the existing api() call.
- static/index.html: replace stale hardcoded 'v0.50.87' with '—'
  placeholder; JS overwrites it as soon as the settings panel opens.
- server.py: replace hardcoded 'HermesWebUI/0.50.38' server_version with
  'HermesWebUI/' + WEBUI_VERSION.lstrip('v') — stays in sync automatically.
- Dockerfile: add ARG HERMES_VERSION=unknown and write api/_version.py
  so Docker images (where .git is excluded) still show the correct tag.
- .github/workflows/release.yml: pass build-args: HERMES_VERSION=${{ github.ref_name }}
  to the Docker build step on tag pushes.
- .gitignore: exclude api/_version.py (generated by Docker/CI, never committed).

No manual 'update the version badge' step is required going forward.
Tagging is sufficient — the badge and HTTP header update automatically.

Tests: 18 new tests in tests/test_version_badge.py covering the full
resolution chain, /api/settings injection, HTML placeholder, JS wiring,
and server.py import. 1596 tests pass total.

* fix: address review feedback on PR #790

- api/updates.py: replace exec() with regex parse for api/_version.py
  (no supply-chain risk from build artifact; exec unnecessary for one assignment)
- api/updates.py: cap git describe timeout at 3s (was 10s — import-time
  stall on NFS/.git would block server startup unnecessarily)
- server.py: lstrip('v') → removeprefix('v') (lstrip strips chars not prefix)
- server.py: emit bare 'HermesWebUI' when version is 'unknown' rather than
  'HermesWebUI/unknown' (log aggregators expect semver-ish suffix or none)
- CHANGELOG.md: add v0.50.124 entry for this user-visible change
- tests: rename exec-error test to reflect regex behaviour; add tests for
  removeprefix usage and unknown-version header guard (1598 tests total)

---------

Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-20 20:36:53 -07:00
Nathan Esquenazi
81072d34d6 Merge pull request #788 from nesquena/fix/ci-test-default-model-isolation
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(tests): restore conftest default model in teardown — fixes CI ordering failure
2026-04-20 19:35:13 -07:00
Nathan Esquenazi
e91325db25 fix(config): invalidate model-list TTL cache on default-model change
set_hermes_default_model() calls reload_config() which resyncs _cfg_mtime,
so the mtime check inside get_available_models() never fires and the POST
response returns the stale cached default. Explicitly drop the TTL cache
after reload so the next read recomputes. Fixes the CI failure in
test_default_model_updates_hermes_config which the prior teardown-only
fix in this PR did not actually address.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:32:33 -07:00
nesquena-hermes
629d4290ed fix(tests): restore conftest default model in test_default_model_updates_hermes_config — fixes CI ordering failure
The test was restoring original_model from /api/models, but after prior runs
the config.yaml model.default field could be stale, causing the restore to
bake in the wrong value. Fix: always restore to TEST_DEFAULT_MODEL (the
conftest-injected env value) for deterministic ordering-independent cleanup.

Also exposes TEST_DEFAULT_MODEL from _pytest_port.py so other tests that
mutate the default model can use it for clean teardown.

TESTING.md: update automated test count from 1353 to 1578.
2026-04-21 02:25:14 +00:00
nesquena-hermes
28b4777b5a fix(ui): hide duplicate close button in workspace header at mobile width (#783)
Some checks failed
Release & Docker / release (push) Has been cancelled
At the @media(max-width:900px) breakpoint both .close-preview and .mobile-close-btn were visible simultaneously. Since boot.js wires both to handleWorkspaceClose(), only the mobile-close-btn needs to show at that width. Adds .close-preview{display:none} to the 900px media block.

Fixes #781
2026-04-21 00:58:02 +00:00
nesquena-hermes
b6d335feaa perf: TTL cache for model list + incremental session index (#780)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes AWS IMDS timeout on model dropdown. Incremental index writes.

Co-authored-by: starship-s <starship-s@users.noreply.github.com>
2026-04-21 00:33:03 +00:00
nesquena-hermes
a7e8b1ab83 fix(streaming): eagerly release session lock in cancel_stream() (#778)
Some checks failed
Release & Docker / release (push) Has been cancelled
cancel_stream() now pops STREAMS/CANCEL_FLAGS/AGENT_INSTANCES and clears session.active_stream_id immediately after signalling cancel. Fixes sessions permanently stuck at 409 when the agent thread is blocked in a bad tool call. Session cleanup runs outside STREAMS_LOCK to preserve lock ordering.

Fixes #653

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
2026-04-20 23:54:40 +00:00
nesquena-hermes
c34892be44 fix(streaming): guard newer AIAgent kwargs with inspect for hermes-agent compat (#775)
Some checks failed
Release & Docker / release (push) Has been cancelled
Uses inspect.signature() to check which params AIAgent accepts. Fixes #772.
2026-04-20 23:23:19 +00:00
nesquena-hermes
98cd318413 fix(sessions): surface get_cli_sessions() failures via logger.warning (#769)
Some checks failed
Release & Docker / release (push) Has been cancelled
Logs warnings instead of silently returning [] on DB errors. Fixes #634.
2026-04-20 23:13:54 +00:00
nesquena-hermes
94a04ddd40 fix(ui): persist session queue to sessionStorage across page refresh (#768)
Some checks failed
Release & Docker / release (push) Has been cancelled
Queued follow-up messages now survive page refresh. Persisted atomically in queueSessionMessage/shiftQueuedSessionMessage. On reload: if agent still active, queue is silently hydrated (done handler drains it); if idle, first entry is restored as a composer draft with a toast. Stale entries discarded.

Fixes #660
2026-04-20 23:04:09 +00:00
nesquena-hermes
765d8520d4 fix(streaming): quota error detection, error persistence, stream_end session_id fix (#767)
Some checks failed
Release & Docker / release (push) Has been cancelled
- quota_exhausted error type: distinguishes credit exhaustion from rate limits
- Streaming errors persisted to session file so they survive page reload
- _error flag excludes persisted errors from subsequent LLM API calls
- stream_end and title SSE events use original session_id (not s.session_id which rotates during context compaction)

Fixes #739, #652, #653
2026-04-20 22:48:19 +00:00
nesquena-hermes
76e602af25 feat: remove bubble_layout setting end-to-end (#777)
Some checks failed
Release & Docker / release (push) Has been cancelled
Removes the bubble_layout toggle from Settings, all persistence, CSS, i18n strings, and the UI docs demo. The CSS was already effectively dead. Users with a saved bubble_layout value in settings.json get a clean migration via _SETTINGS_LEGACY_DROP_KEYS.

Credit: @aronprins (PR #760 / #777)

Co-authored-by: aronprins <aronprins@users.noreply.github.com>
2026-04-20 22:34:45 +00:00
nesquena-hermes
63f9b719bb fix(config): use Hermes config.yaml as single source of default model (#773)
Some checks failed
Release & Docker / release (push) Has been cancelled
Removes split-brain where WebUI Settings persisted default_model separately from Hermes runtime config.yaml. New POST /api/default-model endpoint writes to config.yaml. Existing saved values migrated on first load.

Fixes #761

Co-authored-by: aronprins <aronprins@users.noreply.github.com>
2026-04-20 22:12:01 +00:00
nesquena-hermes
f35ac3a727 fix(ui): streamline slash sub-argument autocomplete (#771)
Some checks failed
Release & Docker / release (push) Has been cancelled
Adds sub-argument suggestions for /model, /personality, /reasoning slash commands. /reasoning is now discoverable from the first slash. Keyboard navigation pre-selects the first item. Fixes bug where no-arg commands (/clear, /new, /stop, etc.) would loop the dropdown on selection.

Fixes #632

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-04-20 22:04:28 +00:00
Frank Song
0dd5d6f21c feat(ui): add sidebar density mode to session list (#764)
Some checks failed
Release & Docker / release (push) Has been cancelled
Adds compact/detailed toggle for the session list sidebar. Compact is the default (no behavior change for existing users). Detailed mode shows message count and model; profile names only appear when mixing sessions across profiles.

Fixes #673

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-04-20 19:43:40 +00:00
nesquena-hermes
a8979f74d5 fix(ui): dark-mode user bubbles use subtle tint + thinking card collapsible — v0.50.111 (#759)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Summary

Rebased on behalf of @aronprins from fork branch `codex/dark-user-bubbles`. Two asset-only commits (PR screenshot add/remove) were dropped; the two code commits are applied cleanly on top of current master (v0.50.110).

### What changed

**Dark-mode user bubbles** (`static/style.css`):
- `:root.dark` now overrides `--user-bubble-bg`/`--user-bubble-border` to `var(--accent-bg-strong)` (a 15% opacity tint) — keeps the bubble visually subdued in dark skins instead of a glaring bright accent fill
- Removes 6 per-skin `--user-bubble-text` hacks (ares, mono, slate, poseidon, sisyphus, charizard); text falls back to `var(--text)` which is already correct in dark mode
- Adds `--user-bubble-placeholder` token; edit-area box-shadow now uses `--focus-ring` instead of hardcoded `rgba(255,255,255,.15)`

**Thinking card collapsibility** (`static/ui.js` + `static/style.css`):
- `_thinkingMarkup()` now includes `onclick` toggle and chevron affordance, matching the compression reference card pattern
- `.thinking-card-header` gets `display:flex; gap:8px` for proper icon/label/chevron alignment

**Tests**: 2 new in `test_bugbatch_apr2026.py` (dark bubble token contract + no-per-skin-hack assertion), 2 updated in `test_ui_card_animation.py` (flex header layout + onclick pattern).

1520 passed. QA 20/20. Browser verified: dark mode bubble uses subtle tint, thinking card toggles correctly.

(credit: @aronprins)
2026-04-20 01:12:45 -07:00
nesquena-hermes
711d8bb6c0 fix(ui): hover-only footer chrome with timestamps for both user and assistant — v0.50.110 (fixes #680) (#758)
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash merge of PR #717 — rebased on behalf of @franksong2702.

## What it does

Fixes #680. Footer chrome (timestamps, copy, edit, regenerate) is now hover-only for both user and assistant message rows, consistent throughout the conversation. The last assistant turn keeps cumulative usage visible at rest; timestamp and actions are revealed inline on hover in the same row.

Key changes:
- `static/ui.js`: new `_formatMessageFooterTimestamp()` (local timezone, cross-day fuller format); `timeHtml` no longer gated to user-only; last assistant usage moved from separate `.msg-usage` div to inline `.msg-usage-inline` span in the footer
- `static/style.css`: `.msg-foot-with-usage` class + rules; assistant footer opacity changed from 0.45 to 0 (hover-only); `:focus-within` alongside `:hover` for keyboard users
- `api/streaming.py`: `_restore_reasoning_metadata()` now preserves `_ts`/`timestamp` for unchanged historical messages
- `tests/test_sprint49.py`: 8 new tests covering rendering contract, hover CSS, timestamp preservation

Tests: 1518 passed. QA: 20/20. Browser verified. Reviewed and approved by @nesquena and @aronprins.
2026-04-20 00:53:19 -07:00
nesquena-hermes
a1c5c395e5 fix(tests): pin _cfg_mtime in _models_with_cfg to prevent ordering-dependent failure — v0.50.109 (fixes #754) (#756)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Summary

Fixes the ordering-dependent test failure in `test_custom_provider_display_name.py` (issue #754).

**Root cause:** `_models_with_cfg()` patches `config.cfg` then calls `get_available_models()`. That function checks `config.yaml`'s mtime on every call — if it has changed since the last `reload_config()`, it calls `reload_config()` again, which reads from disk and silently overwrites the patch. Any test that writes `config.yaml` (e.g. via `save_settings()`) before this test runs changes the mtime and triggers the reload.

**Fix:** Pin `config._cfg_mtime` to the current `config.yaml` mtime before calling `get_available_models()`, then restore it in the `finally` block. This is the same pattern already used in `test_model_resolver.py` (lines 249, 393).

**Also restores `_cfg_mtime`** in the `finally` block so the patch leaves no side effects on subsequent tests.

## Tests

1510 passed — the previously-flaky test now passes regardless of which tests ran before it.

Closes #754
2026-04-20 00:39:24 -07:00
nesquena-hermes
69570ca77c release: v0.50.102–v0.50.108 batch (code blocks, utf-8, image URLs, deletion warning, PermissionError, Docker docs, kimi-k2.5) (#755)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Batch release: v0.50.102 – v0.50.108

Seven self-built PRs reviewed and approved by @nesquena, now consolidated into a single release branch.

### Included fixes

| Version | PR | What it fixes |
|---|---|---|
| v0.50.102 | #746 | Code blocks lose newlines when not preceded by blank line (fixes #745) |
| v0.50.103 | #743 | `encoding='utf-8'` on `write_text()` in `api/profiles.py` — Windows `.env` detection (fixes #741) |
| v0.50.104 | #735 | Agent `MEDIA:localhost:*` image URLs rewritten to `document.baseURI` — remote users get working images (fixes #642) |
| v0.50.105 | #736 | Profile deletion warning strengthened: "permanently deleted, cannot be undone" across all 6 locales (fixes #637) |
| v0.50.106 | #738 | Catch `PermissionError` in `_signing_key()` — three-container Docker UID mismatch no longer crashes all HTTP requests |
| v0.50.107 | #737 | Docs: three-container UID/GID alignment guide in README + `HERMES_UID`/`HERMES_GID` forwarded in compose (fixes #645) |
| v0.50.108 | #742 | Add `kimi-k2.5` to Kimi/Moonshot provider model list (fixes #740) |

### Testing
- **pytest**: 1510 passed, 1 warning (1 pre-existing unrelated failure excluded)
- **QA harness**: 20/20 passed (`~/WebUI/scripts/run-browser-tests.sh`)
- **Browser**: layout, slash autocomplete width, edit button, image URL rewrite, profile deletion dialog all verified

All PRs reviewed and approved by @nesquena. Ready to merge and tag **v0.50.108**.
2026-04-20 00:26:55 -07:00
nesquena-hermes
aa767d28d0 fix(renderer): preserve newlines in code blocks during paragraph split (#745) (#746)
Squash merge PR #746: fix(renderer): preserve newlines in code blocks — v0.50.102

All tests pass (1510). Browser QA verified. Reviewed and approved by @nesquena.
2026-04-20 00:04:27 -07:00
nesquena-hermes
78c4f1e425 fix: null/empty session model must not trigger index rebuild — v0.50.101 (#753)
## Summary

Follow-up to #751/#752. Code review identified a case where `_normalize_session_model_in_place` could call `session.save()` (which triggers a full session index rebuild) for sessions with `model: null` or missing model field.

Root cause: `_resolve_compatible_session_model(None)` returns `(default_model, True)` when a default exists — which was interpreted as "changed, needs save." But there's nothing to correct for a session with no model; the default is just a fallback for display purposes, not a cross-provider correction worth persisting.

Fix: capture `original_model` before calling `_resolve_compatible_session_model`. Only call `session.save()` if `original_model` was non-empty and actually changed.

Adds a test asserting `save_calls == []` when `session.model is None`.

No behavior change for sessions with a real model (the primary use case of #751 is unaffected).
2026-04-19 23:44:46 -07:00
nesquena-hermes
81ba420716 fix: custom/unknown model prefixes must not be stripped on provider switch — v0.50.100 (#752)
## Summary

Regression fix for #751.

Models with custom or unrecognized prefixes (e.g. `custom-provider/my-model`, `test/import-model`) were being incorrectly replaced with the active provider default. Root cause: `_normalize_provider_id("custom-provider")` matched the `"custom"` prefix and returned `"custom"`, which ≠ `active_provider` → normalization fired.

Two-part fix:
1. Add `"custom"` and `"openrouter"` to the `model_provider` exclusion set in `_resolve_compatible_session_model` (parallel to the existing `active_provider` guard)
2. Return `""` for unknown prefixes in `_normalize_provider_id` so the `if model_provider` truthiness check safely short-circuits

Adds a regression test covering `custom-provider/`, `test/`, `my-local-llm/`, and `lmstudio-community/` prefixes.

## Tests

1499 passed, 0 failures (was 2 failures before this fix)
2026-04-19 23:27:24 -07:00
nesquena-hermes
7f16a41a31 fix: normalize stale session models after provider switch — v0.50.99 (#751)
## Summary

Rebased-on-behalf of @likawa3b (originally PR #748 — stale base).

Sessions can outlive provider changes. When an old session still points to a model from a previous provider (e.g. `gemini-3.1-pro-preview` after switching the agent to OpenAI Codex), starting a chat hits the wrong backend and fails silently.

This PR adds a lightweight normalization pass:
- `_normalize_provider_id()` maps common prefixes to canonical provider IDs
- `_resolve_compatible_session_model()` checks the session model's provider against `active_provider` and returns the default model if they differ
- `_normalize_session_model_in_place()` is called at GET `/api/session` — corrects and persists stale models once
- Chat start also normalizes via `_resolve_compatible_session_model()` and returns `effective_model` in the response
- `messages.js` applies `effective_model` back to the UI/localStorage/dropdown if set

Closes #748

## Tests

1498 passed (2 pre-existing ordering failures unrelated to this PR; 5 new tests added in `test_provider_mismatch.py`).

**Original author:** @likawa3b
2026-04-19 23:22:26 -07:00
nesquena-hermes
c68420d9aa fix(ui): constrain slash autocomplete width to composer — v0.50.98 (closes #633) (#750)
## Summary

Rebased-on-behalf of @franksong2702 (originally PR #728 — had CHANGELOG conflict after #747 merged).

Moves `#cmdDropdown` from outside `composer-box` to inside it, so the `position:absolute` anchor is scoped to the composer width rather than the full chat panel. CSS updated to use `bottom:calc(100% + 4px)` and `width:auto;max-width:100%` for clean upward positioning.

Closes #633

## Changes
- `static/index.html` — moved `cmd-dropdown` div inside `composer-box`
- `static/style.css` — updated `.cmd-dropdown` positioning (remove `margin-bottom`, use `bottom:calc(100% + 4px)`, add `width:auto;max-width:100%`)
- `tests/test_sprint50.py` — 2 new structural tests verifying DOM position and CSS rules

## Tests
1493 passed, 1 warning (2 new tests added)

**Original author:** @franksong2702
2026-04-19 23:17:00 -07:00
Frank Song
aa78175cca fix(ui): restrict edit to latest user message (#747)
fix(ui): restrict edit to latest user message (#747)

Only the latest user turn shows the pencil/edit affordance. Older user
messages remain read-only (copy + timestamp still work). Avoids the
misleading implication that historical messages can be lightly edited
when the actual action truncates the session and restarts the
conversation from that point.

Closes #744

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-04-19 23:11:49 -07:00
nesquena-hermes
da1fdca22c docs: fix docker-compose files + add three-container config — v0.50.96 (PR #708)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes gateway port exposure, workspace path expansion, HERMES_WEBUI_STATE_DIR default, and adds three-container reference config with dashboard. All ports localhost-bound by default.
2026-04-19 07:10:05 +00:00
nesquena-hermes
067d96bb30 feat: add full Russian (ru-RU) localization — v0.50.95 (PR #713)
Some checks failed
Release & Docker / release (push) Has been cancelled
Full Russian locale — 389/389 English keys, Slavic plural forms, native Cyrillic. Rebased from PR #605 with rebase artifacts fixed. Login page Russian added to api/routes.py. Credits: @DrMaks22 (translation), @renheqiang (PR #605 author).

Co-authored-by: DrMaks22 <DrMaks22@users.noreply.github.com>
Co-authored-by: renheqiang <renheqiang@users.noreply.github.com>
2026-04-19 06:47:24 +00:00
nesquena-hermes
e637965388 fix: robust mic toggle + Tailscale MediaRecorder fallback — v0.50.94 (PR #715)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes and extends PR #683 (MatzAgent). recognition.start() is now a real call. _isRecording race guard added with correct reset in all paths. localStorage persistence of fallback flag. Closes #683.

Co-authored-by: MatzAgent <MatzAgent@users.noreply.github.com>
2026-04-19 06:28:14 +00:00
nesquena-hermes
66fbfbaa2b fix: gateway sync race condition + hybrid session data loss — v0.50.93 (PR #714)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes and extends PR #676 (yunyunyunyun-yun). Race guard in sessions.js SSE handler; prefix-equality check in routes.py _handle_session_import_cli. Closes #676.

Co-authored-by: yunyunyunyun-yun <yunyunyunyun-yun@users.noreply.github.com>
2026-04-19 06:18:28 +00:00
nesquena-hermes
877a32f49c fix: XML tool-call leak + workspace empty-state + notification text — v0.50.92 (PR #712)
Some checks failed
Release & Docker / release (push) Has been cancelled
Strips <function_calls> XML from assistant messages before rendering, adds workspace file panel empty-state messages, and changes notification description from 'tab' to 'app'. 16 new tests. Fixes #702, #703, #704.
2026-04-19 05:40:37 +00:00
nesquena-hermes
0386dc261a feat: slash command parity + skill autocomplete — v0.50.91 (PR #711)
Some checks failed
Release & Docker / release (push) Has been cancelled
Combines PR #618 (@renheqiang) slash command parity (/retry /undo /stop /title /status /voice) with PR #701 (@franksong2702) skill autocomplete. 1469 tests pass. Closes #460.

Co-authored-by: renheqiang <renheqiang@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-04-19 05:37:44 +00:00
nesquena-hermes
17e965b52f chore: block agent-local files from git (.claude/ CLAUDE.md AGENTS.md etc)
Removes stale webui-mvp AGENTS.md and expands .gitignore to block all agent-local context files from being committed. Fixes .claude/* → .claude/ (directory block).
2026-04-19 05:37:42 +00:00
nesquena-hermes
d3a686a266 fix(compress): prefer persisted reference handoff after completion — v0.50.90 (PR #699 by @franksong2702)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes the /compress reference card showing only a short 3-line summary immediately after compression. Now prefers the persisted compaction message (full handoff) over the raw API summary, matching what is shown after page reload. Closes #695.
2026-04-19 04:29:07 +00:00
nesquena-hermes
3cd38b2b31 chore: add CHANGELOG entries for v0.50.88 and v0.50.89
Some checks failed
Release & Docker / release (push) Has been cancelled
Adds entries for #672 (model dropdown fix) and #700 (UTF-8 encoding fix). CHANGELOG-only change.
2026-04-19 04:23:38 +00:00
woaijiadanoo
d7071cd424 fix: explicit UTF-8 encoding on all read_text() calls — v0.50.89 (PR #700 by @woaijiadanoo)
Fixes config loading failures on Windows with non-UTF-8 default locales (GBK, Shift_JIS etc). All Path.read_text() calls in api/config.py and api/profiles.py now specify encoding='utf-8'.
2026-04-19 04:22:28 +00:00
nesquena-hermes
e0ad593801 fix(model dropdown): stop injecting default_model into unrelated providers — v0.50.88 (PR #672 by @franksong2702)
Some checks failed
Release & Docker / release (push) Has been cancelled
2026-04-19 04:18:43 +00:00
Frank Song
75e4f8b201 fix(model dropdown): stop injecting default into unrelated providers 2026-04-19 08:18:24 +08:00
nesquena-hermes
352354790f fix: streaming scroll override, Gemini 3.x models, read-only workspace, two-container UID — v0.50.87 (closes #677 #669 #670 #668)
Some checks failed
Release & Docker / release (push) Has been cancelled
- #677: renderMessages() and appendThinking() use scrollIfPinned() during stream; scroll threshold 80→150px; floating ↓ scroll-to-bottom button added
- #669: Gemini 3.1 Pro Preview, 3 Flash Preview, 3.1 Flash Lite Preview added to all provider sections; gemini-3.1-flash-lite-preview was the missing ID causing API_KEY_INVALID; GEMINI_API_KEY env var detection added
- #670: docker_init.bash guards chown/write-test with [ -w ]; :ro workspace mounts no longer crash startup
- #668: UID/GID auto-detect probes /home/hermeswebui/.hermes and HERMES_HOME before /workspace; two-container Zeabur/Compose setups inherit correct UID automatically
- 18 new tests; 1441 total passing
2026-04-18 17:09:59 +00:00
nesquena-hermes
5266ee26bd feat(ui): searchable model picker with provider group headers — v0.50.86 (PR #659 by @mmartial)
Some checks failed
Release & Docker / release (push) Has been cancelled
- Live search input in model dropdown (filter by name or ID)
- Provider group headers preserved in filtered view
- Clear button, Escape-to-close, No models found empty state
- i18n EN/ES/zh-CN strings
- CSS uses var(--accent) consistent with current theme system
- zh-CN double-escape fix included
- Provider headers regression fix included
- 1423 tests pass

Co-authored-by: mmartial <mmartial@users.noreply.github.com>
2026-04-18 16:27:36 +00:00
nesquena-hermes
5c2840e2da fix(onboarding): remove CLI fast path from _provider_oauth_authenticated — fixes 4 test failures
Some checks failed
Release & Docker / release (push) Has been cancelled
The hermes_cli fast path ignored hermes_home, returning True from real system auth for OAuth providers. Removed — auth now scoped to hermes_home/auth.json only. 1423 passed, 0 failed.
2026-04-18 07:23:16 +00:00
nesquena-hermes
75e6595e06 feat: add MiniMax M2.7 to fallback model list and fix env var detection — PR #650 by @octo-patch
Some checks failed
Release & Docker / release (push) Has been cancelled
MiniMax M2.7/highspeed added to _FALLBACK_MODELS. MINIMAX_API_KEY and MINIMAX_CN_API_KEY added to env scan tuple so os.environ is checked. 11 tests. Independent review by @nesquena confirmed correct, needed rebase only.
2026-04-18 07:18:20 +00:00
nesquena-hermes
20a5f48a1f fix(config): load provider models from config.yaml in model dropdown — PR #644 by @ccqqlo
Some checks failed
Release & Docker / release (push) Has been cancelled
Providers in config.yaml with explicit models: list were silently ignored. Fix extends the model-list builder to check cfg.providers[pid].models, covering both dict and list formats. Also includes providers only in config.yaml (not _PROVIDER_MODELS). 5 regression tests added. Independent review by @nesquena.
2026-04-18 07:14:03 +00:00
nesquena-hermes
ad6e76e48e chore: reorder CHANGELOG v0.50.77-v0.50.82 in descending order
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-18 07:09:06 +00:00
nesquena-hermes
b49de92893 feat(/compress): manual session compression with focus topic — closes #469 (PR #619 by @franksong2702)
POST /api/session/compress with optional focus_topic. Transcript-inline cards: command, running, complete (collapsible green), reference. /compact alias kept. Fixes: var(--green) undefined color, focus_topic 500-char cap. Independent review by @nesquena (4 passes).
2026-04-18 06:55:04 +00:00
nesquena-hermes
b1aa1cfa4d fix(title): auto-title extraction for tool-heavy first turns — closes #639 (PR #640 by @franksong2702)
The auto-title extractor now uses _looks_invalid_generated_title() to distinguish tool-call preambles from substantive agentic replies. Fixes _is_provisional_title() whitespace normalization. 5 regression tests added. Independent review by @nesquena (a553b2b+a0ca9fe).
2026-04-18 06:52:45 +00:00
nesquena-hermes
8c68ea8823 fix: skill panel auto-open, thinking scroll, nav icon alignment, Safari zoom — closes #643 #638 #636 #630 (PR #647)
Four self-contained CSS/JS fixes: skill click auto-opens workspace panel (ensureWorkspacePreviewVisible before api call), thinking card body scrolls when open (overflow-y:auto), nav tab icons properly centered (display:flex), Safari iOS zoom prevented (textarea 14px->16px). Independent review by @nesquena confirmed all four correct.
2026-04-18 06:50:14 +00:00
nesquena-hermes
ec48c482e2 fix(config): default model empty string — no unavailable OpenAI model for non-OpenAI users — closes #646 (PR #649)
DEFAULT_MODEL now defaults to "" instead of "openai/gpt-5.4-mini". Guards added in model-list builder so empty default does not create blank model entries. Adds 3 tests in test_issue646.py. Independent review by @nesquena.
2026-04-18 06:46:43 +00:00
nesquena-hermes
bded1cf906 fix(streaming): strip Gemma 4 thinking token delimiter in all paths — closes #607
Fixes <|turn|>thinking delimiter (was wrong as <|turn>thinking) in api/streaming.py, static/messages.js, and static/ui.js. Adds 13 regression tests. Independent review by @nesquena.
2026-04-18 06:45:39 +00:00
Aron Prins
7cb5547056 feat(theme): replace color scheme system with light/dark + accent skins (PR #627 by @aronprins)
Independent review by @nesquena confirmed all blockers resolved. Theme×skin two-axis system replaces old monolithic color schemes. Closes #627. Co-Authored-By: aronprins <aronprins@users.noreply.github.com>
2026-04-18 06:37:09 +00:00
nesquena-hermes
f3f23abd4e fix(csp): allow external https images in img-src — closes #608
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Hermes Agent <agent@hermes>
2026-04-16 23:34:21 -07:00
nesquena-hermes
d6267f4d31 chore: CHANGELOG v0.50.75 + version badge (#620 test isolation fix) (#621)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Hermes Agent <agent@hermes>
2026-04-16 23:06:16 -07:00
nesquena-hermes
e7b8ab4d70 fix: harden test server isolation — HERMES_BASE_HOME + strip provider keys + mock _get_active_hermes_home in unit tests (#620)
Fixes the root cause of OPENROUTER_API_KEY being overwritten with test-key-fresh on every pytest run.

Three-layer fix:
1. Unit tests: mock _get_active_hermes_home in TestApplyOnboardingSetupGuard so .env writes land in /tmp, never ~/.hermes
2. Test server subprocess: add HERMES_BASE_HOME=TEST_STATE_DIR to hard-lock profile resolution inside the server process
3. Test server subprocess: strip real provider keys (OPENROUTER_API_KEY etc.) from the inherited env before server starts

Reviewed and approved by @nesquena. 1373 passed, 0 skipped.
2026-04-16 23:03:32 -07:00
nesquena-hermes
79428f93c6 fix: catch OSError from SETTINGS_FILE.exists() — Docker UID-mismatch 500 crash (#614)
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges PR #614. Fixes Docker 500-on-every-request crash from PermissionError in load_settings() (issue #570 follow-up).

Both SETTINGS_FILE.exists() call sites now catch OSError and fall back to defaults. Reviewer nits addressed: removed unused imports/var in tests, improved log message to say "inaccessible?" instead of "permission denied?". Rebased clean onto v0.50.73. 1373 tests passing, QA harness green.
2026-04-16 20:16:07 -07:00
nesquena-hermes
a2ea15b557 fix: add favicon (SVG + PNG + ICO), fix static MIME types (#613)
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges PR #613. Adds favicon to the app (was missing entirely — blank tab icon). 1371 tests passing, QA harness green. Review by independent agent (see PR comments). Follow-up commit addresses all three reviewer notes: hoisted _STATIC_MIME to module scope, fixed charset=utf-8 being appended to binary MIME types, confirmed correct MIME types on all three favicon formats.

Co-authored-by: tiansiyuan <tiansiyuan@users.noreply.github.com>
2026-04-16 20:11:02 -07:00
franksong2702
692ba68e42 fix(title): strip markdown labels and skip empty placeholders in auto-title (#611)
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges PR #611 (@franksong2702). Fixes two edge cases in auto-generated session titles.

1. Strip Markdown labels (`**Session Title:**`, `Title:`) from sanitizer output — these were being persisted verbatim when the LLM emitted them.
2. Skip empty assistant tool-call placeholder messages when extracting the first exchange for title generation — previously the empty row could be latched onto instead of the first real answer.

Also tightens the title prompt to explicitly forbid Markdown, bullets, and label prefixes.

1371 tests passing, QA harness green.

Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-16 18:51:00 -07:00
nesquena-hermes
2484409b7a fix: HERMES_WEBUI_DEFAULT_WORKSPACE wins over settings.json; trust DEFAULT_WORKSPACE subtree (#610)
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges PR #610. Fixes Docker workspace env var override and trust validation (issue #609). 1367 tests passing, QA harness green. Reviewed by independent agent (see PR comments).
2026-04-16 18:09:16 -07:00
nesquena-hermes
b608f8837e Update image sources and attributes in README (#606) 2026-04-16 15:32:40 -07:00
nesquena-hermes
d5bea959a5 chore: CHANGELOG v0.50.70 + version badge (post-merge meta for PR #587 by @aronprins)
Post-merge meta commit for PR #587 by @aronprins. CHANGELOG entry for v0.50.70 + version badge bump. No code changes. 1361 tests passing.
2026-04-16 14:14:55 -07:00
Aron Prins
9a3dc10d93 feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Some checks failed
Release & Docker / release (push) Has been cancelled
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70

Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.

Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.

Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).

Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].

Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.

Workspace panel FOUC fix: [data-workspace-panel] set at parse time.

Docs: docs/ui-ux/index.html + two-stage-proposal.html.

Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.

Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 14:04:42 -07:00
nesquena-hermes
25d38a467a fix: Docker UID/GID auto-detect from workspace mount + message count tests — v0.50.69
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes #569: docker_init.bash auto-detects WANTED_UID/WANTED_GID from the mounted /workspace UID at Phase 1, before usermod remaps the container user. On macOS, host UIDs start at 501 — the default 1024 caused an empty workspace. Guards against root (0). Fallback 1024 preserved. Closes #579: topbar already correctly filters tool messages; sidebar count removed in #584. Regression tests added. Reviewed and approved by @nesquena. 1347 tests passing.
2026-04-16 12:19:25 -07:00
nesquena-hermes
6c5911a79f fix: light theme dialogs, workspace panel snap, model cache staleness, docker-compose docs — v0.50.68
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes four bugs + locks in one existing fix with regression tests.

Closes #594 (light theme dialogs), #576 (workspace panel snap), #585 (stale model list after CLI change), #567 (docker-compose macOS UID docs). Confirms and tests #590 (transcribing spinner already present).

Reviewed and approved by @nesquena. 1340 tests passing.
2026-04-16 11:55:18 -07:00
nesquena-hermes
54e83fb8b6 feat: support subpath mount via reverse proxy — v0.50.67 (PR #588 by @vcavichini)
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges feature from PR #588 by @vcavichini. Dynamic <base href> injection + api() helper slash-stripping enables deploying hermes-webui behind a reverse proxy at any subpath without configuration. Also fixes pre-existing bug: api/upload was using location.origin instead of location.href (closes #596). Co-authored-by: vcavichini <vcavichini@users.noreply.github.com>
2026-04-16 11:20:08 -07:00
nesquena-hermes
8a1bc134fa chore: CHANGELOG + v0.50.66 version badge (post-merge for PR #582)
Docs/version-only follow-up for PR #582. Nathan authorized full merge pipeline in session.
2026-04-16 10:21:39 -07:00
suinia
b5fc32b18d fix: pass runtime route details into webui agent — v0.50.66
Forwards `api_mode`, `acp_command`, `acp_args`, and `credential_pool` from the resolved runtime provider into `AIAgent.__init__()` in the WebUI streaming path. Fixes Codex account switching and credential pool support for WebUI sessions. Also adds 6 defensive variable initializations to prevent NameError in cleanup paths.

Tests: 1329 passed, 0 skipped. Full TestRuntimeRouteInjection suite passes.

PR by @suinia. Rebased and CHANGELOG added by maintainer.

Co-authored-by: suinia <suinia@users.noreply.github.com>
2026-04-16 10:20:42 -07:00
nesquena-hermes
db1240dde5 fix: HERMES_WEBUI_SKIP_ONBOARDING unconditional + guard against config writes + 10 skipped tests fixed — v0.50.65
Fixes two SKIP_ONBOARDING bugs and eliminates 10 permanently-skipped integration tests.

- SKIP_ONBOARDING=1 now honoured unconditionally (no longer gated on chat_ready)
- apply_onboarding_setup refuses to write config/env files when SKIP_ONBOARDING is set
- TestMediaEndpointIntegration (6) and TestOnboardingGateIntegration (4): collection-time
  skip guards removed; server reachability checked at runtime with fail() not skip()

Tests: 1327 passed, 0 skipped.

Admin merge — self-built PR, Nathan authorized full merge process in session.
2026-04-16 10:19:10 -07:00
nesquena-hermes
2efc1fb8e8 chore: CHANGELOG + v0.50.64 badge + Today-bucket test (post-merge follow-up for #584)
Some checks failed
Release & Docker / release (push) Has been cancelled
Admin merge — docs-only follow-up: CHANGELOG entry, version badge v0.50.64, one new test. No code logic. Nathan authorized end-to-end merge in session.
2026-04-16 10:09:51 -07:00
Aron Prins
a9a22ee751 fix(sidebar): declutter session items — drop message count, model, and source-tag badges (v0.50.64)
Squash-merges PR #584 by @aronprins.

Drops the meta row (message count, model slug, source-tag badge) from every sidebar session item. Each session now renders as a single title line — visible session count roughly doubles at typical viewport height.

Changes merged verbatim from contributor branch, plus maintainer additions:
- CHANGELOG entry for v0.50.64
- Version badge bump to v0.50.64
- New test: test_relative_time_today_bucket (closes minor coverage gap from code review)

Co-authored-by: aronprins <aronprins@users.noreply.github.com>
2026-04-16 09:58:53 -07:00
nesquena-hermes
a512f2020e feat: MCP toolsets in WebUI + onboarding fix for non-standard providers — v0.50.63
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges PR #578 (rebased from #574 by @renheqiang + #575 by @nesquena-hermes). MCP server toolsets now included in WebUI sessions; onboarding wizard no longer fires for non-standard providers. 1331 tests pass. Nathan override applied for self-built #575.
2026-04-15 23:39:07 -07:00
nesquena-hermes
45426bdcd1 fix: make hermes-agent source optional in Docker startup — v0.50.62
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges PR #577 (rebased from #573 by @nesquena). Docker hard-exit on missing hermes-agent → graceful warning. 1319 tests pass. Fixes #570.
2026-04-15 23:22:26 -07:00
nesquena-hermes
360379136b feat(upload): support Excel and Word file attachments — v0.50.61
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges PR #571 (rebased from contributor PR #566 by @renheqiang). Adds .xls/.xlsx/.doc/.docx to the file picker and MIME map. 1319 tests pass.
2026-04-15 22:43:31 -07:00
nesquena-hermes
e4fec9e4e0 test: skip onboarding config tests when PyYAML unavailable, remove duplicate definition — v0.50.60
Some checks failed
Release & Docker / release (push) Has been cancelled
Merges #564. Adds PyYAML skip guards to two onboarding tests. Removes duplicate _HAS_YAML/_needs_yaml block. No production code changed. 1319 tests pass.
2026-04-15 20:45:42 -07:00
nesquena-hermes
8cf10b152b fix: false Connection lost message after settled stream disconnect — v0.50.59
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: 避免流结束后误插入 Connection lost 错误

* chore: bump version to v0.50.59, update CHANGELOG

---------

Co-authored-by: fiver <fiver@example.com>
Co-authored-by: Hermes Agent <agent@hermes>
2026-04-15 20:25:31 -07:00
nesquena-hermes
07c25f0766 fix(models): show named custom provider label in model dropdown — v0.50.58
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(models): show named custom provider label in model dropdown — v0.50.58
2026-04-15 18:32:36 -07:00
Hermes Agent
4f79b3f941 chore: bump version to v0.50.58, update CHANGELOG 2026-04-16 01:32:15 +00:00
Hermes Agent
54d0ee5f6c fix(models): show named custom provider label in model dropdown instead of generic 'Custom' — PR #558
Named custom_providers entries (those with a 'name' field) now get their own
dropdown group using the configured name (e.g. 'Agent37') instead of the
generic 'Custom' label. Unnamed entries still fall back to 'Custom'.

Closes #557.
2026-04-16 01:31:04 +00:00
Hermes Agent
3e1ba1b783 fix(models): show named custom provider label in model dropdown instead of generic 'Custom'
When a custom_providers entry in config.yaml has a 'name' field (e.g. 'Agent37'),
the web UI model picker now uses that name as the group header instead of the
generic 'Custom' label.

Previously all custom_providers entries were bucketed under 'custom' which
rendered as 'Custom' in the dropdown optgroup — losing the named identity the
user set up during onboarding.

Changes:
- Track named custom providers as 'custom:<slug>' keys internally so multiple
  named providers can coexist as separate groups
- When building model groups, emit each named provider under its own display
  name (e.g. 'Agent37') rather than falling through to the generic label
- Unnamed entries (no 'name' field) still fall back to the 'Custom' group
- When all entries are named, the bare 'Custom' bucket is suppressed

Adds 7 tests covering single named provider, multiple named providers,
multiple models in same named provider, unnamed fallback, and mixed cases.

Fixes #557
2026-04-16 01:09:39 +00:00
nesquena-hermes
0a9b952d4c feat(sessions): auto-summarize session titles after first exchange (fixes #495) — v0.50.57
Some checks failed
Release & Docker / release (push) Has been cancelled
feat(sessions): auto-summarize session titles after first exchange (fixes #495) — v0.50.57
2026-04-15 17:07:28 -07:00
Hermes Agent
8864001941 chore: bump version to v0.50.57, update CHANGELOG 2026-04-16 00:07:08 +00:00
Hermes Agent
7e8ed4afff feat(sessions): auto-summarize session titles after first exchange (fixes #495) — PR #535
After the first user/assistant exchange, generates a concise session title
in a background daemon thread using the first user message + first visible
assistant reply as input. Title updates live in the UI via a new 'title' SSE event.
The stream now terminates with 'stream_end' instead of 'done' so the title
generation thread has time to finish before the client disconnects.

Provisional titles (first-message substrings) are replaced; manual renames
are preserved; generation only runs once per session (llm_title_generated flag).
Includes MiniMax token budget handling and a local heuristic fallback.

Additional fixes applied in agent review:
- messages.js: fix JS syntax error (mismatched quote in setComposerStatus)
- messages.js: fix broken template literal in error hint rendering
- messages.js: restore approval queue multi-slot fix (approval_id, pendingCount,
  _approvalCurrentId) that was accidentally removed
- api/streaming.py: fix MiniMax thinking delimiter regex (<|channel|>)
- tests/test_issue487b.py: fix DeprecationWarning (raw string docstring)

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-04-16 00:05:53 +00:00
Hermes Agent
215f7eff4d fix(review): 4 issues found in agent review of PR #535
BUG-1 (CRITICAL): messages.js line 522 — mismatched quote in
setComposerStatus('Reconnecting…') caused JS syntax error on the
reconnect path.

BUG-2 (HIGH): messages.js line 491 — broken template literal
'\\n\\n*{d.hint}*' restored to '\n\n*${d.hint}*'. Error hint
text was non-functional (missing $ prefix and escaped newlines).

BUG-3 (HIGH): messages.js — showApprovalCard(pending, pendingCount),
_approvalCurrentId, and approval_id in respondApproval() were removed,
regressing the simultaneous approval queue fix from PR #546. Restored
all three, including the '1 of N pending' counter and poll passthrough.

BUG-4 (LOW): api/streaming.py — MiniMax thinking delimiter regex
missing closing pipe: <|channel> -> <|channel|> in both
_strip_thinking_markup() and _looks_invalid_generated_title().

ALSO: test_issue487b.py docstring changed to raw string to fix
DeprecationWarning for invalid escape sequence '\s'.
2026-04-16 00:00:22 +00:00
franksong2702
a4ce9ccc99 fix(messages): keep inflight tool-call regression intact 2026-04-15 23:59:36 +00:00
Frank Song
8ff3fd9442 feat(sessions): auto-summarize provisional session titles 2026-04-15 23:59:36 +00:00
nesquena-hermes
53ce8a107b fix: version badge v0.50.55, CHANGELOG entry, QA innerHTML allowlist
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: version badge v0.50.55, CHANGELOG entry, QA innerHTML allowlist
2026-04-15 16:40:50 -07:00
Hermes Agent
ec44a437a2 fix: version badge v0.50.54→v0.50.55, add CHANGELOG entry for v0.50.55
The v0.50.55 release (Docker honcho fix) missed the index.html version
badge bump and CHANGELOG entry. Caught during post-session QA sweep.
2026-04-15 23:40:11 +00:00
nesquena-hermes
400b1721d7 fix: install hermes-agent[honcho] extra in Docker init (fixes #553)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: install hermes-agent[honcho] extra in Docker init (fixes #553)
2026-04-15 16:22:34 -07:00
Hermes Agent
fbce1093b9 fix: install hermes-agent[honcho] extra in Docker init (fixes #553)
docker_init.bash was installing hermes-agent without the [honcho] optional
extra, causing honcho-ai to be missing from /app/venv. All Honcho memory
tools would fail with 'Honcho session could not be initialized' on every
fresh Docker build.

Adds [honcho] to the uv pip install invocation on line 238.
2026-04-15 23:22:20 +00:00
nesquena-hermes
c0bffa15f1 chore: update OpenRouter and provider model lists — v0.50.54
Some checks failed
Release & Docker / release (push) Has been cancelled
chore: update OpenRouter and provider model lists — v0.50.54
2026-04-15 16:04:20 -07:00
Hermes Agent
27d3f9543e chore: bump version to v0.50.54, update CHANGELOG 2026-04-15 23:04:06 +00:00
Hermes Agent
51767f9d90 chore: update OpenRouter and provider model lists — PR #551
OpenRouter dropdown updated to 14 current models across 7 providers.
All slugs verified against live OpenRouter catalog.

Removed: o4-mini, claude-sonnet-4-5 (temporarily, re-added), gemini-2.5-pro,
         gemini-2.0-flash, llama-4-scout, llama-4-maverick.
Added:   gpt-5.4, claude-opus-4.6, claude-sonnet-4-5, gemini-3.1-pro-preview,
         gemini-3-flash-preview, deepseek-r1, qwen3-coder, qwen3.6-plus,
         grok-4.20, mistral-large-latest.
Fixed:   gemini slug -preview suffix, grok-4-20 -> grok-4.20 (dot not dash),
         stale Nous label, mistralai/qwen/x-ai added to PROVIDER_MODELS/DISPLAY.
2026-04-15 23:03:13 +00:00
Hermes Agent
9d4c075e2b fix: correct OpenRouter model slugs from live catalog verification
- google/gemini-3.1-pro -> google/gemini-3.1-pro-preview (not GA yet)
- google/gemini-3-flash -> google/gemini-3-flash-preview (not GA yet)
- x-ai/grok-4-20 -> x-ai/grok-4.20 (dot not dash in slug)
- Fix stale label: 'Gemini 2.5 Pro (via Nous)' -> 'Gemini 3.1 Pro Preview (via Nous)'
2026-04-15 23:00:29 +00:00
Hermes Agent
f5c4e110a4 chore: add Qwen3 Coder, Qwen3.6 Plus, Grok 4.20; drop Llama
- Remove llama-4-scout and llama-4-maverick
- Add qwen/qwen3-coder, qwen/qwen3.6-plus, x-ai/grok-4-20
- Add qwen and x-ai to _PROVIDER_MODELS and _PROVIDER_DISPLAY
2026-04-15 22:54:18 +00:00
Hermes Agent
4c142da3f6 chore: expand OpenRouter list per feedback — Claude 4.5 gen, Opus, R1, Maverick, Mistral
OpenRouter / _FALLBACK_MODELS (7 → 13 models):
- Add gpt-5.4 (full OpenAI alongside Mini)
- Restore claude-sonnet-4-5 (keep 4.5 generation alongside 4.6)
- Add claude-opus-4.6 (flagship)
- Add deepseek-r1 (popular reasoning model)
- Add llama-4-maverick (larger open-weight option)
- Add mistral-large-latest (Mistral via OpenRouter)

Structural:
- Add mistralai to _PROVIDER_MODELS for correct prefix-stripping routing
- Add mistralai to _PROVIDER_DISPLAY for correct group label
2026-04-15 22:27:55 +00:00
Hermes Agent
3b53b3f4f6 chore: update OpenRouter and provider model lists
OpenRouter / _FALLBACK_MODELS (8 → 7 models):
- Remove o4-mini (reasoning specialist, not a general-purpose pick)
- Remove claude-sonnet-4-5 (superseded by 4.6)
- Add gemini-3-flash as fast/cheap Google option
- Update gemini-2.5-pro → gemini-3.1-pro (current flagship)
- Better provider labels (Google, DeepSeek, Meta instead of 'Other')

Direct-API providers:
- openai: replace o4-mini with gpt-5.4 (general-purpose pairing with Mini)
- google / gemini: gemini-2.5-pro → 3.1-pro, gemini-2.0-flash → 3-flash
- Copilot, Nous, opencode-zen: same Gemini updates throughout

Test: update test_fallback_still_has_o4_mini → test_fallback_has_gpt54
2026-04-15 22:20:25 +00:00
nesquena-hermes
69effc7b22 fix: preserve slash model IDs for custom endpoints (fixes #548) — v0.50.53
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: preserve slash model IDs for custom endpoints (fixes #548) — v0.50.53
2026-04-15 15:13:23 -07:00
Hermes Agent
7bfba201da chore: bump version to v0.50.53, update CHANGELOG 2026-04-15 22:13:14 +00:00
Hermes Agent
dc2334c5a3 fix(review): use _PROVIDER_MODELS check instead of custom-only guard
The original fix preserved full IDs only when config_provider == 'custom',
which broke existing tests expecting prefix-stripping for known namespaces
like 'openai/' and 'google/'.

The correct heuristic: strip the prefix only when it is a known provider
namespace (i.e. prefix in _PROVIDER_MODELS — 'openai', 'google', 'anthropic',
etc.). Unknown prefixes like 'zai-org' are intrinsic to the model ID and must
be preserved. This satisfies both the DeepInfra use case (#548) and the
existing #433 regression tests.
2026-04-15 22:11:15 +00:00
eba8
bd55379886 fix: preserve slash model IDs for custom endpoints 2026-04-15 20:06:34 +00:00
nesquena-hermes
392c315d4b fix: queue simultaneous approval requests per session (fixes #527) — v0.50.52
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: queue simultaneous approval requests per session (fixes #527) — v0.50.52
2026-04-15 12:42:32 -07:00
Hermes Agent
25fae902d3 chore: bump version to v0.50.52, update CHANGELOG 2026-04-15 19:42:11 +00:00
Hermes Agent
d6b58b9ce0 fix: queue simultaneous approval requests per session (fixes #527)
Changes _pending from a single overwriting dict value to a list,
so parallel tool calls each get their own approval slot.

api/routes.py:
- Wraps submit_pending() to append to a list and assign a stable
  approval_id (uuid4) to each entry.
- _handle_approval_pending() returns the first queued entry plus
  pending_count so the UI can show '1 of N'.
- _handle_approval_respond() pops by approval_id (falls back to
  oldest entry for backward-compat with old clients).
- Backward-compat: legacy single-dict values in _pending are
  handled without crashing.

static/messages.js:
- respondApproval() sends approval_id in the POST body.
- showApprovalCard() accepts pendingCount, shows '1 of N pending'
  counter when multiple approvals are queued.
- _approvalCurrentId tracks the approval_id of the displayed card.
- Poll loop passes pending_count to showApprovalCard.

static/index.html:
- Adds approvalCounter element for the '1 of N' display.

tests/test_approval_queue.py:
- 14 tests: static-analysis checks (Python + JS + HTML),
  functional tests that inject two simultaneous approvals and
  verify both are surfaced and independently resolvable.
2026-04-15 19:16:14 +00:00
nesquena-hermes
ac839e0d01 fix: strip orphaned tool messages before API calls (fixes #534) — v0.50.51
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: strip orphaned tool messages before API calls (fixes #534) — v0.50.51
2026-04-15 12:07:09 -07:00
Hermes Agent
ce4e01ea92 chore: bump version to v0.50.51, update CHANGELOG 2026-04-15 19:06:54 +00:00
Hermes Agent
4f7db62c58 fix: strip orphaned tool messages before API calls (fixes #534) — PR #542 2026-04-15 19:06:02 +00:00
nesquena-hermes
3033fb65e3 fix(themes): swap Prism syntax-highlighting theme on light/dark switch (#505) — v0.50.50
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(themes): swap Prism syntax-highlighting theme on light/dark switch (#505) — PR #530
2026-04-15 12:05:12 -07:00
Hermes Agent
03df7132d0 chore: bump version to v0.50.50, update CHANGELOG 2026-04-15 19:02:43 +00:00
armorbreak001
50d7d1cf88 fix(themes): swap Prism syntax-highlighting theme on light/dark switch
The Prism CSS was hardcoded to prism-tomorrow (dark-only), so code
blocks stayed dark even when switching to Light or other non-dark themes.

- Add id='prism-theme' to the <link> element for runtime lookup
- In _applyTheme(), swap href between prism-tomorrow (dark) and
  prism (light) based on resolved theme
- Skips DOM write when the target href is already active

Fixes #505
2026-04-15 19:01:52 +00:00
nesquena-hermes
e4688425ab fix: respect IME composition in all Enter submit flows (#531) — v0.50.49
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: respect IME composition in all Enter submit flows (#531) — PR #537
2026-04-15 11:58:31 -07:00
Hermes Agent
9220a876bc fix: strip orphaned tool messages before sending history to API (fixes #534)
Extends _sanitize_messages_for_api() with a two-pass approach:
1. Collect all tool_call_ids declared in assistant messages (handles
   both OpenAI 'id' and Anthropic 'call_id' field names).
2. Drop any tool-role messages whose tool_call_id was not declared
   by a preceding assistant message.

Strictly-conformant providers (Mercury-2/Inception, newer OpenAI
models) reject histories with orphaned tool results with a 400 error:
'Message has tool role, but there was no previous assistant message
with a tool call.' This can happen when histories are edited, when
switching between providers, or when partial messages are stored.

Adds 13 regression tests covering: valid roundtrip preservation,
multiple tool calls, partial orphan filtering, Anthropic call_id,
edge cases (None tool_calls, missing tool_call_id, non-dict entries).
2026-04-15 16:57:31 +00:00
Hermes Agent
e077d110c3 chore: bump version to v0.50.49, update CHANGELOG 2026-04-15 16:46:53 +00:00
Hermes Agent
8f7bee7b34 fix: respect IME composition in all Enter submit flows — PR #537
Adds isComposing guards to every Enter keydown handler so CJK/IME
users are never sent mid-composition (fixes issue #531):
- boot.js: chat composer + command dropdown Enter handler
- sessions.js: session rename, project create/rename Enter handlers
- ui.js: app dialog, message edit Enter-to-save, workspace rename

Ships 3 new regression tests (test_ime_composition.py).

Co-authored-by: vansour <vansour@users.noreply.github.com>
2026-04-15 16:45:52 +00:00
vansour
dc43a30af7 test: loosen IME guard regression assertions 2026-04-15 23:21:56 +08:00
vansour
74dee6b665 fix: respect IME composition in Enter submit flows 2026-04-15 23:12:47 +08:00
nesquena-hermes
96c4102aa7 fix: toast when model switched during active session (#419) — PR #529
Some checks failed
Release & Docker / release (push) Has been cancelled
2026-04-15 08:05:19 +00:00
Hermes Agent
d3251fdbfd chore: bump version to v0.50.48, update CHANGELOG 2026-04-15 08:04:24 +00:00
Hermes Agent
31196d42af fix: show toast when model is switched during active session (#419)
When a user switches the model via the model picker while a session has
existing messages, a toast now informs them: 'Model change takes effect
in your next conversation'. This prevents confusion when the model
dropdown updates visually but the running conversation continues with
the original model.

Implementation: 4-line addition in modelSelect.onchange in boot.js,
after the existing provider-mismatch warning. Checks S.messages.length
(the reliable in-memory array) and guards showToast with typeof.

Synthesized from PRs #516 (armorbreak001), #517 and #518 (cloudyun888).
Placement follows #518's correct boot.js approach. Reference corrected
from S.session.messages to S.messages (always initialized by loadSession).

4 new tests in test_provider_mismatch.py::TestModelSwitchToast.

Co-authored-by: armorbreak001 <armorbreak001@users.noreply.github.com>
Co-authored-by: cloudyun888 <cloudyun888@users.noreply.github.com>
2026-04-15 08:04:03 +00:00
nesquena-hermes
1050c673e6 fix/feat: batch fixes v0.50.47 — root workspace, custom providers, cron cache, system theme (PR #523)
Some checks failed
Release & Docker / release (push) Has been cancelled
2026-04-15 07:54:26 +00:00
Hermes Agent
178251a5c0 chore: bump version to v0.50.47, update CHANGELOG 2026-04-15 07:52:23 +00:00
Hermes Agent
21a7564afd test: add 22 tests covering batch fixes v0.50.47 (#506-#521) 2026-04-15 07:47:18 +00:00
Hermes Agent
44a544362f feat: add System (auto) theme following OS prefers-color-scheme (#504)
Synthesized from PRs #506, #509, #514 (all by armorbreak001 and cloudyun888).

Implementation:
- static/index.html: flicker-prevention head script resolves 'system' to
  'dark'/'light' via matchMedia before first paint. Adds 'System (auto)'
  as first option in theme picker. onchange calls _applyTheme().
- static/boot.js: new _applyTheme(name) helper — resolves 'system' via
  matchMedia, sets data-theme, registers a MQ change listener so the UI
  tracks OS switches live. loadSettings() now calls _applyTheme() instead
  of direct data-theme assignment.
- static/commands.js: adds 'system' to valid /theme command names,
  delegates apply to _applyTheme().
- static/panels.js: _settingsThemeOnOpen reads from localStorage (preserves
  'system' string, not the resolved 'dark'/'light'). _revertSettingsPreview
  calls _applyTheme() so reverting to 'system' correctly re-enables OS tracking.
- static/i18n.js: cmd_theme description now lists 'system' first in all 5
  locales (en, es, de, zh-Hans, zh-Hant).

Design choices vs submitted PRs:
- No separate system-theme.js file (unnecessary indirection).
- matchMedia listener does NOT POST to /api/settings (OS can change rapidly;
  persisting on every OS switch would hammer the server).

Co-authored-by: armorbreak001 <armorbreak001@users.noreply.github.com>
Co-authored-by: cloudyun888 <cloudyun888@users.noreply.github.com>
2026-04-15 07:45:20 +00:00
Hermes Agent
36830e3cd1 fix: invalidate cron skill picker cache on form open and after skill save (#502)
Two complementary cache-busting strategies for the stale cron skill picker:

1. On cron form open (toggleCronForm): always null _cronSkillsCache before
   fetching, so freshly created skills are immediately visible without a
   page reload. Previously the cache was only populated once and never
   invalidated.

2. On skill save (submitSkillSave): null _cronSkillsCache after a successful
   write so the next cron form open is forced to re-fetch. Mirrors the
   existing _skillsData=null pattern one line above.

Fixes: #502
Co-authored-by: armorbreak001 <armorbreak001@users.noreply.github.com>
2026-04-15 07:43:00 +00:00
Hermes Agent
7ea7331f26 fix: show custom_providers models regardless of active provider (#515 #519)
When a user has custom_providers configured in config.yaml, their custom
models should appear in the model picker even if active_provider is set
to a different provider (e.g. openrouter). Previously, the custom provider
was always discarded from detected_providers when active_provider != 'custom',
making custom models invisible.

Fix: only discard 'custom' if there are no custom_providers entries.

Co-authored-by: cloudyun888 <cloudyun888@users.noreply.github.com>
Co-authored-by: shruggr <shruggr@users.noreply.github.com>
2026-04-15 07:42:12 +00:00
Hermes Agent
eb760a2158 fix: allow /root workspace path; guard against split on missing [Attached files]
Removes /root from _BLOCKED_SYSTEM_ROOTS in api/workspace.py, allowing
Hermes running as root (e.g. Docker, VPS) to use /root as a workspace
without a 'system directory' rejection.

Fixes a fragile string split in api/streaming.py: base_text extraction
now guards against msg_text that contains no '[Attached files:' marker,
preventing the split from producing empty-string on those messages.

Fixes: #510, partial fix from #521 (workspace + split guard only).
Co-authored-by: ccqqlo <ccqqlo@users.noreply.github.com>
2026-04-15 07:41:36 +00:00
Hermes Agent
0b96f08b3e chore: bump version to v0.50.46, update CHANGELOG 2026-04-15 07:35:25 +00:00
nesquena-hermes
4f1623520d feat: clarify dialog flow and refresh recovery (#520) - merge PR #522
Some checks failed
Release & Docker / release (push) Has been cancelled
2026-04-15 07:27:57 +00:00
Hermes Agent
505bfc6a9a feat: clarify dialog flow and refresh recovery (#520)
Implements and stabilizes the clarify dialog UX in Hermes WebUI.

New clarify state module (api/clarify.py):
  - Per-session pending queue with threading.Event unblocking
  - Gateway notify registration/unregistration
  - Duplicate clarify deduplication while unresolved
  - resolve/clear helpers

New clarify HTTP endpoints (api/routes.py):
  - GET /api/clarify/pending
  - POST /api/clarify/respond
  - GET /api/clarify/inject_test (loopback-only, for tests)

Streaming integration (api/streaming.py):
  - clarify_callback wired to AIAgent.run_conversation()
  - SSE 'clarify' event emitted to WebUI
  - Blocks tool flow until response/timeout/cancel
  - 409 guard: session already has an active stream returns active_stream_id
  - MCP lazy discovery on first stream start

Frontend (static/messages.js, ui.js, sessions.js, index.html, style.css, i18n.js):
  - Clarify card with numbered choices + Other + free-text input
  - Composer lock while clarify is active
  - DOM self-healing if card node is removed during rerender
  - SSE 'clarify' event listener + fallback polling (1.5s interval)
  - Session switch / reconnect stops/starts clarify polling
  - 409 conflict: reattaches to active stream and queues user input
  - CLARIFY_MIN_VISIBLE_MS = 30000 timer dedup (mirrors approval card pattern)
  - i18n keys in en/es/de/zh-Hans/zh-Hant locales

Tests:
  - tests/test_clarify_unblock.py: 14 new tests (queue, callbacks, HTTP endpoints)
  - tests/test_sprint30.py: 31 new clarify tests (HTML, CSS, i18n, JS, streaming)
  - tests/test_sprint36.py: expand search window (stopClarifyPolling pushes setBusy further)

Total tests: 1246 (was 1209)

Co-authored-by: franksong2702 <138988108+franksong2702@users.noreply.github.com>
2026-04-15 07:25:52 +00:00
Hermes Agent
1bd0341243 fix: expand test_sprint36 search window for setBusy after stopClarifyPolling additions 2026-04-15 07:24:53 +00:00
Frank Song
ccba2f5c01 feat: harden clarify dialog flow and refresh recovery 2026-04-15 13:10:50 +08:00
nesquena-hermes
45d3dc0f68 fix: suppress N/A source_tag in session list sidebar (fixes #429)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: suppress N/A source_tag in session list sidebar (fixes #429)
2026-04-14 15:15:05 -07:00
nesquena-hermes
69cd0832de fix: suppress N/A source_tag in session list sidebar (fixes #429)
fix(renderer): extend _al_stash to include <img> tags — fixes broken image rendering
2026-04-14 15:14:57 -07:00
Hermes Agent
7b9f08c774 fix: suppress N/A source_tag in session list sidebar (#429)
- sessions.js _formatSourceTag(): return null for unrecognised tags
  instead of raw string — prevents legacy 'N/A' values from surfacing
- sessions.js metaBits push: guarded with _stLabel null check so only
  known platform labels appear in the session metadata line
- sessions.js [SYSTEM:] title fallback: drop raw s.source_tag middle
  term, fall back directly to 'Gateway' for unknown sources

7 new tests in test_issue429.py.
1 updated test in test_sprint40_ui_polish.py (new guarded push pattern).

Closes #429
2026-04-14 22:14:31 +00:00
Hermes Agent
2810233af4 fix(renderer): extend _al_stash to include <img> tags, preventing autolink from mangling src= URLs
Bug: the autolink pass stashed <a> tags (via _al_stash) before running,
but did not stash <img> tags. When ![alt](url) was converted to an <img>
tag by the image pass, the subsequent autolink regex matched the URL
inside src="..." and wrapped it in <a href="...">url</a>, producing
src="<a href="...">url</a>" — a completely broken image source.

Fix: extend the _al_stash regex from:
  (<a\b[^>]*>[\s\S]*?<\/a>)
to:
  (<a\b[^>]*>[\s\S]*?<\/a>|<img\b[^>]*>)

This stashes both <a> and self-closing <img> tags before autolink runs,
then restores them after, so the URL inside src= is never touched.

Adds 7 regression tests in tests/test_issue487b.py.
2026-04-14 22:09:36 +00:00
nesquena-hermes
642f4536f0 docs: update TESTING.md and ROADMAP.md to v0.50.44 / 1195 tests
docs: update TESTING.md and ROADMAP.md to v0.50.44 / 1195 tests
2026-04-14 15:06:29 -07:00
Hermes Agent
f0d49b5b59 docs: update TESTING.md and ROADMAP.md to v0.50.44 / 1195 tests 2026-04-14 22:06:11 +00:00
nesquena-hermes
e6447ebad2 fix: code-in-table CSS sizing + markdown image rendering (fixes #486, #487)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: code-in-table CSS sizing + markdown image rendering (fixes #486, #487)
2026-04-14 14:52:59 -07:00
Hermes Agent
887893ecd1 fix: code-in-table CSS sizing + markdown image rendering (#486, #487)
- static/style.css: add td code / th code rules (font-size 0.85em,
  padding 1px 4px, vertical-align baseline) for both .msg-body and
  .preview-md to fix cramped inline code in table cells (#486)

- static/ui.js inlineMd(): add image pass (![alt](url) → <img
  class=msg-media-img>) running while _code_stash is active (protects
  image syntax inside backticks), add _img_stash (\x00G) to shield
  rendered <img> src= from autolink, add img to SAFE_INLINE (#487)

- static/ui.js renderMd() outer: add image pass before outer link pass
  for images in plain paragraphs, add img to SAFE_TAGS allowlist (#487)

- tests/test_issue486_487.py: 45 new tests covering CSS source checks,
  JS source structure, rendering behaviour, and combination edge cases
  (code + image + link in same table cell, image inside code span, etc.)

Closes #486, closes #487
2026-04-14 21:52:34 +00:00
nesquena-hermes
75de03c99f Merge pull request #478 from nesquena/release/v0.50.43
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.43 — markdown rendering fixes + KaTeX CSP
2026-04-14 14:23:38 -07:00
Hermes Agent
d8ab326b73 fix(renderer): fix two remaining renderMd issues found during browser QA
1. ** inside  was corrupted** — the outer bold/italic pass at line 480 ran
   after the outer backtick→<code> pass at line 457, causing esc() to corrupt <code> tags
   into &lt;code&gt; inside <strong>. Fix: add _ob_stash to protect <code> tags from
   the outer bold/italic pass.

2. **Table cells with [label](url) produced double <a> tags** — the outer [label](url) pass
   ran BEFORE the table regex, converting links to <a> tags in the raw table source.
   Then inlineMd() processed those <a> tags again and autolink re-linked the URL inside
   href="...". Fix: moved the outer link pass to AFTER the table pass so table cells
   get their links from inlineMd() only, which has its own _link_stash protection.
2026-04-14 21:22:20 +00:00
Hermes Agent
7753e954e5 docs: correct v0.50.43 test count to 1150 2026-04-14 21:15:46 +00:00
Hermes Agent
2343dc1d85 docs: v0.50.43 CHANGELOG + version bump (test count TBD) 2026-04-14 21:15:02 +00:00
Hermes Agent
85f1017514 fix(csp): allow cdn.jsdelivr.net for font-src so KaTeX fonts load (fixes #477) 2026-04-14 21:14:33 +00:00
Hermes Agent
eb7ec5bac3 fix(renderer): backtick code spans inside bold/italic no longer get esc'd 2026-04-14 21:14:00 +00:00
Hermes Agent
b673006b7f fix(renderer): address review feedback on PR #475 2026-04-14 21:13:53 +00:00
Nathan Esquenazi
5a79dd0dc9 fix: remove double semicolon in inlineMd link stash restore 2026-04-14 21:13:34 +00:00
Hermes Agent
0a570ada87 fix(renderer): prevent double-linking and esc() corruption in renderMd() 2026-04-14 21:13:33 +00:00
nesquena-hermes
53acc8e0e1 Merge pull request #476 from nesquena/release/v0.50.42
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.42 — session display fixes + model UX polish (sprint 42)
2026-04-14 14:08:46 -07:00
Hermes Agent
34b98285a1 fix(ui): add custom option to <select> when model ID not in curated list (enables custom model IDs) 2026-04-14 21:06:23 +00:00
Hermes Agent
e228b1414f fix(tests): shared helpers in test_sprint42.py; correct test count to 1130 2026-04-14 21:04:37 +00:00
Hermes Agent
bb445ffe9a docs: v0.50.42 CHANGELOG, version bump (test count TBD) 2026-04-14 20:58:30 +00:00
Hermes Agent
2eb0679104 fix(tests): consolidate sprint-42 test_sprint42.py — all 20 tests in one file 2026-04-14 20:58:01 +00:00
Hermes Agent
12949a2771 feat(ui): add custom model ID input to model picker dropdown (fixes #444) 2026-04-14 20:56:56 +00:00
Nathan Esquenazi
7b0fb246ee fix: merge duplicate const lastAsst declarations into single lookup 2026-04-14 20:56:54 +00:00
Hermes Agent
f86581e3e5 fix(ui): persist thinking/reasoning trace across page reload (fixes #427) 2026-04-14 20:56:53 +00:00
Hermes Agent
3c5ca2db62 fix(sessions): replace [SYSTEM: titles with platform name for gateway sessions (fixes #441) 2026-04-14 20:56:52 +00:00
Hermes Agent
c7381ee3f1 fix(ui): context indicator prefers latest usage over stale session data (fixes #437) 2026-04-14 20:56:50 +00:00
nesquena-hermes
32669f4a5b Merge pull request #459 from nesquena/release/v0.50.41
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.41 — MEDIA: inline image rendering in chat (fixes #450)
2026-04-14 12:37:14 -07:00
Hermes Agent
c9a0e02301 docs: v0.50.41 CHANGELOG, version bump, test count (1117) 2026-04-14 19:36:14 +00:00
Hermes Agent
bfb9bbb0bf fix: use _content_disposition_value() for RFC 5987 filename encoding in /api/media 2026-04-14 19:35:53 +00:00
Nathan Esquenazi
5507dae3d7 fix: restrict /api/media allowed roots — remove ~ (home dir) 2026-04-14 19:35:52 +00:00
Hermes Agent
0349df6ee4 feat(ui): render MEDIA: images inline in web UI chat (fixes #450) 2026-04-14 19:35:52 +00:00
nesquena-hermes
8c36203dd4 Merge pull request #457 from nesquena/release/v0.50.40
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.40 — session UI polish, test port isolation, 6 bug fixes
2026-04-14 12:13:11 -07:00
Hermes Agent
c4d1e8c5d0 docs: correct v0.50.40 test count to 1098 2026-04-14 19:11:04 +00:00
Hermes Agent
c0c0195f7f fix(tests): consolidate sprint-40 test file, fix module-scope vars, update sidebar-time assertion 2026-04-14 19:10:23 +00:00
Hermes Agent
8199fa333e docs: v0.50.40 CHANGELOG and version bump (test count TBD) 2026-04-14 19:07:10 +00:00
Hermes Agent
77769750c2 fix(panels): apply profile default workspace to new session after profile switch (fixes #424) 2026-04-14 19:06:37 +00:00
Nathan Esquenazi
b3ad60d2c9 fix(routing): strip provider prefix from model ID when custom base_url is configured (fixes #433) 2026-04-14 19:06:35 +00:00
Nathan Esquenazi
85d8aad0ae fix(ux): mute Telegram badge color and format source tag as display name (fixes #442) 2026-04-14 19:06:33 +00:00
Nathan Esquenazi
f1590fdb07 fix(sessions): return None instead of 'unknown' for missing gateway session model (fixes #443) 2026-04-14 19:06:22 +00:00
Nathan Esquenazi
3776b09f4a fix(ui): active session title uses var(--gold) instead of hardcoded #e8a030 (fixes #440) 2026-04-14 19:05:26 +00:00
Hermes Agent
2400e14a31 fix(sidebar): hide session timestamps entirely to give titles full width 2026-04-14 19:04:49 +00:00
Nathan Esquenazi
69b0a905a4 fix(sidebar): move session timestamp below title to prevent truncation 2026-04-14 19:04:49 +00:00
Hermes Agent
c3251ea97d fix(tests): auto-derive unique port+state-dir per worktree (fixes parallel pytest) 2026-04-14 19:04:48 +00:00
nesquena-hermes
924c833878 Merge pull request #448 from nesquena/release/v0.50.39
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.39 — orphan session fix + first-password session continuity
2026-04-14 11:01:11 -07:00
Nathan Esquenazi
5fd7dc0c17 docs: v0.50.39 CHANGELOG, version bump, test count (1078) 2026-04-14 17:54:54 +00:00
Nathan Esquenazi
a4136f2da5 fix(gateway): filter orphan sessions from SSE watcher (HAVING COUNT > 0) 2026-04-14 17:54:30 +00:00
Nathan Esquenazi
3c3cae89f8 fix(tests): test_sprint45 isolation + zh i18n keys + server version string
- test_sprint45.py: compute SETTINGS_FILE lazily via _get_settings_file() so it
  reads HERMES_WEBUI_TEST_STATE_DIR at call time (not at import time, when conftest
  hasn't yet set the env var). Fixes test isolation across all 1078 tests.
- test_sprint45.py: use auth cookie in teardown when clearing password post-test.
- test_sprint45.py: remove test_synced_version_strings (checks local-patch version).
- static/i18n.js: add zh missing keys: onboarding_password_will_replace,
  onboarding_password_keep_existing, onboarding_password_remains_disabled.
- server.py: revert server_version to HermesWebUI/0.50.38 (matches master).
2026-04-14 17:54:06 +00:00
SaulgoodMan-C
8b857d9efc login-module-patch: sync to v0.50.36-local.1 2026-04-14 17:54:06 +00:00
nesquena-hermes
8d1c257ea8 docs: correct test count to 1075 in TESTING.md and CHANGELOG (#447)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 10:17:22 -07:00
nesquena-hermes
6e303fbd93 Merge pull request #446 from nesquena/release/v0.50.38
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.38 — mobile nav cleanup, Prism highlighting, zh-CN/zh-Hant i18n
2026-04-14 10:15:42 -07:00
Nathan Esquenazi
61ecdaded3 docs: v0.50.38 CHANGELOG, version bump, test count (1073) 2026-04-14 17:14:40 +00:00
Nathan Esquenazi
09e278461c fix(test): update test_sprint10 cron history check for i18n key refactor 2026-04-14 17:14:02 +00:00
Nathan Esquenazi
6347949463 fix(i18n): add onboarding_skip/onboarding_skipped to zh locale 2026-04-14 17:14:02 +00:00
vansour
204dc23c6b fix i18n review comments and locale test robustness 2026-04-14 17:14:01 +00:00
vansour
c4efe96725 feat(i18n): complete zh-CN hardening and locale consistency 2026-04-14 17:14:01 +00:00
Louis Wong
6a513f49b2 fix(ui): add Prism syntax highlighting with light + dark theme token colors
Closes #426:
2026-04-14 17:13:04 +00:00
Aron Prins
db392bd532 feat(ui): remove mobile bottom nav on phones
Closes #425:
2026-04-14 17:13:03 +00:00
nesquena-hermes
b394efce17 Merge pull request #445 from nesquena/pr-422-review
docs: add CONTRIBUTING.md (closes #422)
2026-04-14 09:51:21 -07:00
Aron Prins
28d226f5ce docs: add CONTRIBUTING.md
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
2026-04-14 16:50:24 +00:00
nesquena-hermes
d8aa387c3c Merge pull request #439 from nesquena/release/v0.50.37
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.37 — fix onboarding wizard for existing Hermes users
2026-04-14 09:46:08 -07:00
Nathan Esquenazi
4ad7efe8cf fix(i18n): add onboarding_skip/onboarding_skipped keys to en+es locales 2026-04-14 16:45:13 +00:00
Nathan Esquenazi
57a50591ee fix(onboarding): skip wizard if Hermes already configured
Closes #420:
2026-04-14 16:45:12 +00:00
Nathan Esquenazi
16c58e60f4 docs: v0.50.37 CHANGELOG, version bump, test count 2026-04-14 16:44:58 +00:00
nesquena-hermes
37850a4dfd fix: workspace list cleaner — all 1055 tests pass (#418)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: workspace list cleaner — allow own-profile paths, remove brittle string filter

Two bugs in _clean_workspace_list() caused workspace adds to silently vanish
on the next load, making the duplicate-check test and workspace rename test fail:

1. Brittle string filter: 'if test-workspace in path or webui-mvp-test in path:
   continue' — removed. The test server's workspace IS under these paths, so any
   workspace added during testing got silently dropped on the next load_workspaces()
   call. The p.is_dir() check already handles non-existent paths.

2. Cross-profile filter too broad: 'if p is under ~/.hermes/profiles/: skip' —
   this correctly blocked cross-profile leakage but also blocked the current
   profile's own paths (e.g. ~/.hermes/profiles/webui/webui-mvp-test/...).
   Fixed: only skip if the path is under profiles/ AND under a DIFFERENT profile's
   directory. Paths under the current profile's own home are kept.

* docs: v0.50.36 release — version badge and CHANGELOG

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 00:14:25 -07:00
nesquena-hermes
415270ff03 fix: cross-platform multi-workspace trust boundary (#417)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: relax workspace trust boundary to user home directory

The previous restriction required workspaces to be under DEFAULT_WORKSPACE
(/home/hermes/workspace), which blocked all profile-specific workspaces
(~/CodePath, ~/General, ~/WebUI, ~/Camanji, etc.) since each profile uses
a different directory under home.

New boundary: any directory under Path.home() is trusted.
This still blocks /etc, /tmp, /var, /root, /usr and all paths outside the
user's home, while allowing any legitimate workspace under ~/

Also updates test assertions from 'trusted workspace root' to 'outside'
since the new error message says 'outside the user home directory'.

* fix: workspace trust uses home-dir + saved-list, not single ancestor

Three-layer trust model that works cross-platform and multi-workspace:

1. BLOCKLIST: /etc, /usr, /var, /bin, /sbin, /boot, /proc, /sys, /dev, /root,
   /lib, /lib64, /opt/homebrew — always rejected, even if somehow saved
2. HOME CHECK: any path under Path.home() is trusted — covers ~/CodePath,
   ~/hermes-webui-public, ~/WebUI, ~/General, ~/Camanji simultaneously;
   Path.home() is cross-platform (Linux ~/..., macOS ~/..., Windows C:\Users\...\...)
3. SAVED LIST ESCAPE HATCH: if a path is already in the saved workspace list,
   it's trusted regardless of location — covers self-hosted deployments where
   workspaces live outside home (/data/projects, /opt/workspace, etc.)

None/empty → DEFAULT_WORKSPACE (always trusted, validated at startup)

* docs: v0.50.35 release — version badge and CHANGELOG

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 23:57:51 -07:00
nesquena-hermes
2a7a5ddfaf [security] fix(workspace): restrict session workspaces to trusted roots (#416)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(workspace): restrict session workspaces to trusted roots

* fix: use boot-time DEFAULT_WORKSPACE instead of profile default for trusted workspace root

_profile_default_workspace() reads the agent's terminal.cwd which may differ
from the WebUI's configured workspace root. Use _BOOT_DEFAULT_WORKSPACE (which
respects HERMES_WEBUI_DEFAULT_WORKSPACE for test isolation) to stay consistent
with how new_session() seeds the initial workspace.

* docs: v0.50.34 release — version badge and CHANGELOG

---------

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 23:44:03 -07:00
nesquena-hermes
a5abe51cc5 fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview (#414)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview

Two bugs fixed in the workspace right panel:

1. Duplicate X on desktop (bug): #btnClearPreview (the X icon) was always
   visible alongside #btnCollapseWorkspacePanel (the chevron), producing two
   close controls at once. Fixed in syncWorkspacePanelUI() — on desktop, the X
   is now hidden when no file preview is open (display:none), and only shown
   when the user is viewing a file. The chevron remains as the sole close
   control in browse mode.

2. Mobile X collapses panel instead of dismissing file (bug): .mobile-close-btn
   was calling closeWorkspacePanel() directly, which collapsed the whole panel
   even when a file was open. Changed to handleWorkspaceClose(), which already
   has the correct two-step logic: clear preview first, close panel only if
   no preview is visible.

Files changed:
- static/boot.js: syncWorkspacePanelUI() hides btnClearPreview on desktop
  when hasPreview is false, guarded by !isCompact so mobile is unaffected
- static/index.html: mobile-close-btn onclick changed from
  closeWorkspacePanel() to handleWorkspaceClose()
- tests/test_sprint44.py: 10 new regression tests
- tests/test_mobile_layout.py: updated test_workspace_close_button_present()
  to accept handleWorkspaceClose() as the valid onclick target

* fix: widen test_server_delete_invalidates_index window to 1200 chars

The test extracted a 600-char window starting from the session/delete
handler to check for SESSION_INDEX_FILE. Commit 3cc5839 added session_id
character validation and path traversal guards before the unlink call,
pushing SESSION_INDEX_FILE to ~764 chars from the match — beyond the
600-char limit, causing the test to fail on CI.

Widened the window to 1200 chars, which accommodates any reasonable
amount of guard code before the SESSION_INDEX_FILE.unlink() call.

* docs: v0.50.33 release — version badge and CHANGELOG

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 23:25:26 -07:00
nesquena-hermes
3cc5839bf3 [security] fix(sessions): validate session_id before deleting session files (#412)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(sessions): validate session_id before deleting files

* fix: remove premature session index invalidation before validation check

* docs: v0.50.32 release — version badge and CHANGELOG

---------

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 23:10:46 -07:00
nesquena-hermes
539501ed2b fix: delegate all live model fetching to agent provider_model_ids() (#411)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: delegate all live model fetching to agent's provider_model_ids()

Previously _handle_live_models() maintained its own per-provider logic:
- anthropic, google, gemini returned 'not_supported' (hardcoded exclusions)
- openai-codex had a custom branch (added in v0.50.30)
- openai/copilot had hardcoded base URLs
- other providers fell through to a generic /v1/models fetch

Now the handler delegates entirely to hermes_cli.models.provider_model_ids(),
which is the agent's authoritative resolver:
- anthropic:    live fetch via /v1/models with correct API-key or OAuth headers
- copilot:      live fetch from api.githubcopilot.com/models with Copilot headers
- openai-codex: Codex OAuth endpoint + ~/.codex/ cache fallback
- nous:         live fetch from Nous inference portal
- deepseek, kimi-coding: generic OpenAI-compat /v1/models
- opencode-zen/go: OpenCode live catalog
- openrouter:   curated static list (live returns 300+ which is overwhelming)
- google/gemini, zai, minimax: static list (non-standard or Anthropic-compat endpoints)
- any others:   graceful static fallback

Also removed the client-side skip guard in _fetchLiveModels() (ui.js) that
blocked live fetching for anthropic, google, and gemini.

The hardcoded model lists in _PROVIDER_MODELS remain as the fallback when
credentials are missing or network is unavailable — they are never shown
when live data is available.

* docs: v0.50.31 release — version badge and CHANGELOG

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:57:58 -07:00
nesquena-hermes
c91eaaf05f fix: route openai-codex live model fetch through agent get_codex_model_ids() (#410)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: route openai-codex live model fetch through agent's get_codex_model_ids()

Previously _handle_live_models() grouped openai-codex with openai and sent a
request to https://api.openai.com/v1/models, which returns 403 because Codex
auth is OAuth-based via chatgpt.com, not a standard API key. The live fetch
silently failed and the UI showed only the hardcoded static list.

Now: openai-codex has a dedicated early-exit branch that calls
hermes_cli.codex_models.get_codex_model_ids() — the same path the agent CLI
uses. It resolves models in order: live Codex API (if OAuth token available) >
~/.codex/ local cache > DEFAULT_CODEX_MODELS. This means:

- If the user has a valid Codex OAuth session, the UI gets the exact model list
  their subscription provides (e.g. gpt-5.2, gpt-5.3-codex-spark that aren't
  in the hardcoded list)
- If the OAuth session is expired, falls back to local ~/.codex/ cache
- Always has DEFAULT_CODEX_MODELS as final fallback

Also: improved label generation for Codex model IDs (GPT-5.4 Mini vs GPT 5 4 Mini).
Added 1 structural regression test.

* docs: v0.50.30 release — version badge and CHANGELOG

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:49:04 -07:00
nesquena-hermes
d3fea34c41 fix: correct tool call card rendering on session load after context compaction (#408)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: correct tool call card rendering on session load

Two bugs caused duplicate/incorrect tool call cards when loading
sessions (especially after context compaction):

1. loadSession() sanitized messages (B9 filter) but did NOT update
   the session-level tool_calls array's assistant_msg_idx references.
   Since compact() returns only sanitized messages and recomputes
   tool_calls with indices into the compacted array, the original
   assistant_msg_idx values became stale/misaligned.

2. loadSession() then assigned the broken session-level tool_calls
   directly to S.toolCalls. This prevented renderMessages()'s fallback
   path (which derives tool_calls from per-message tool_calls using
   correct sanitized-array indices) from ever running.

Fix:
- Keep full sanitization loop with index remapping for session-level
  tool_calls (in case they're needed by other code paths).
- Instead of assigning broken session-level tool_calls to S.toolCalls,
  set S.toolCalls=[] so renderMessages() uses the fallback derivation
  from per-message tool_calls, which already have correct indices.

* test: add 8 regression tests for issue #401 tool call index remapping

* docs: v0.50.29 release — version badge and CHANGELOG

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:41:31 -07:00
nesquena-hermes
a2258139f2 fix: expand openai-codex model catalog to match DEFAULT_CODEX_MODELS (#407)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: expand openai-codex model catalog to match agent DEFAULT_CODEX_MODELS

The _PROVIDER_MODELS["openai-codex"] catalog only listed codex-mini-latest,
so the model dropdown for profiles using openai-codex provider (e.g. CodePath)
showed only that one entry — even when the profile's saved default_model was
gpt-5.4 or another standard Codex model.

Updated to match DEFAULT_CODEX_MODELS from hermes_cli/codex_models.py:
- gpt-5.4
- gpt-5.4-mini
- gpt-5.3-codex
- gpt-5.2-codex
- gpt-5.1-codex-max
- gpt-5.1-codex-mini
- codex-mini-latest (kept, relabeled as 'Codex Mini (latest)')

Also adds 2 regression tests: catalog includes gpt-5.4, display name correct.

* docs: v0.50.28 release — version badge and CHANGELOG

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:35:27 -07:00
nesquena-hermes
1345ccccee feat: relative time labels in session sidebar (#406)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat: add relative time to session sidebar

(cherry picked from commit 272be9787fdff75d3da2dbc73175820477a3390e)

* fix: address session sidebar relative-time review feedback

* docs: v0.50.27 release — version badge and CHANGELOG

---------

Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:26:05 -07:00
nesquena-hermes
4de4ed9a15 fix(sessions): redact sensitive titles in session list and search responses (#405)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(sessions): redact titles in list and search responses

* docs: v0.50.26 release — version badge and CHANGELOG

---------

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:20:21 -07:00
nesquena-hermes
04ed0ff43d v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: restore mobile chat scrolling and drawer close (#397)

- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites

Original PR by @Jordan-SkyLF

* fix: preserve imported session timestamps (#395)

- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block

Original PR by @Jordan-SkyLF

* fix(profiles): block path traversal in profile switch and delete flows (#399)

Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.

- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
  ^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
  candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
  both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes

Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)

* feat: add desktop microphone transcription fallback (#396)

Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).

- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
  keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
  MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
  into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
  transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
  returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
  allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks

Original PR by @Jordan-SkyLF

* docs: v0.50.25 release — version badge and CHANGELOG

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:11:45 -07:00
nesquena-hermes
2beebaa6a2 feat: opt-in chat bubble layout (closes #336) (#403)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(ui): opt-in chat bubble layout

Closes #336.

Adds a settings toggle that right-aligns user messages and left-aligns
assistant replies. Off by default - the current full-width layout is
friendlier to code blocks and tool output, so bubbles are strictly
opt-in per the maintainer note on the issue.

Wiring follows the existing token-usage / cli-sessions pattern:

- api/config.py: new bubble_layout bool in _SETTINGS_DEFAULTS and
  _SETTINGS_BOOL_KEYS, validated + persisted like the rest.
- static/style.css: .bubble-layout gated selectors using :has() to
  tag msg-rows by .msg-role.user / .msg-role.assistant without any JS
  changes to message creation. User rows get align-self: flex-end,
  max-width: 75%, and a row-reverse header; assistant rows flex-start.
  A 700px media query widens the max to 92% on narrow screens.
- static/index.html: new checkbox with i18n keys next to the existing
  token-usage toggle.
- static/panels.js: loads the setting into the checkbox, saves it
  back, and toggles body.bubble-layout immediately on save.
- static/boot.js: applies the class on initial load so refreshed
  tabs honor the persisted setting without a flash.
- static/i18n.js: English label + description.

Test suite errors are environmental (test server fails to start on
port 8788 on main as well).

* i18n(es): add Spanish translations for bubble_layout setting

* fix+test: boot.js bubble-layout reset on failure; add 22 tests for issue #336

* docs: v0.50.24 release — version badge and CHANGELOG

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 21:42:01 -07:00
nesquena-hermes
0f8fec7ccd docs: v0.50.23 release — version badge and CHANGELOG (#393)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:46:51 -07:00
nesquena-hermes
12a60faaee fix: add OpenCode Zen and Go provider support (closes #362) (#392)
* Add OpenCode Zen and OpenCode Go provider support

The webui model dropdown had no knowledge of these providers.
When hermes_cli detected them as authenticated, they fell through
to the unknown-provider fallback showing wrong models.

Changes:
- Add opencode-zen and opencode-go to _PROVIDER_DISPLAY
- Add model lists for both to _PROVIDER_MODELS
- Add OPENCODE_ZEN_API_KEY and OPENCODE_GO_API_KEY to env-var fallback detection
- Fix custom:* provider IDs (e.g. custom:my-server) displaying raw ID instead of "Custom"

* Add tests for OpenCode provider registration and detection

---------

Co-authored-by: David Case <david.case@shruggr.cloud>
2026-04-13 18:46:11 -07:00
nesquena-hermes
2acee7fc34 fix: onboarding unblocked for reverse proxy / SSH tunnel deployments (fixes #390) (#391)
Some checks failed
Release & Docker / release (push) Has been cancelled
- Read X-Forwarded-For and X-Real-IP before falling back to raw socket IP
- Add HERMES_WEBUI_ONBOARDING_OPEN=1 env var escape hatch for remote servers
- Error message now includes the env var hint
- 18 new tests (TestOnboardingIPLogic + TestOnboardingSetupEndpoint)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 17:52:07 -07:00
nesquena-hermes
acc14f2f0b docs: update ROADMAP, SPRINTS, TESTING to v0.50.21 (961 tests)
ROADMAP.md:
- Header: v0.49.1/700 → v0.50.21/961
- Sprint history table: 12 new rows covering v0.40 → v0.50.21 (500+ commits)
- Architecture block: updated line counts and module list

SPRINTS.md:
- Header state: v0.36/433 → v0.50.21/961
- 'Where we are now' section updated with parity status
- Historical planning content preserved as reference

TESTING.md:
- Version reference: v0.36.2 → v0.50.21
- Test count: 700 → 961 (two places)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 17:43:16 -07:00
nesquena-hermes
9948fcf1db docs: fix CHANGELOG ordering + README architecture counts
- CHANGELOG: reorder v0.50.19/v0.50.20/v0.50.21 to correct newest-first
  (v0.50.19 was mistakenly at the top above v0.50.21 and v0.50.20)
- README: fix architecture block test count 51 files/802 functions → 61 files/961
- README: update line counts to actual wc -l values:
  routes.py ~2250, streaming.py ~660, ui.js ~1740,
  messages.js ~655, sessions.js ~800

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 17:34:22 -07:00
nesquena-hermes
6a1dda4082 docs: add remaining contributors — Argonaut790, indigokarasu, zenc-cp (complete to 33)
- @Argonaut790 (#239): HTML entity decode fix + Traditional Chinese locale
  (fix shipped in v0.46.0; zh-Hant locale added same PR)
- @indigokarasu (#213): CSS-only visual redesign proposal — design token system
  + icon rail + 7 themes (influenced v0.50.0 design language)
- @zenc-cp (#133): Anti-hallucination guard for ReAct loop — streaming token
  buffer + post-run scrub pattern

README now has 33 contributors covering full project history.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:52:39 -07:00
nesquena-hermes
56944cc0ab docs: update contributors, test count, line counts (v0.50.21)
- Add 21 new contributor entries covering v0.50.x era and all external
  contributions that were incorporated via review branches
- Fix test count: 802 → 961
- Fix line counts for routes.py, streaming.py, ui.js, messages.js, sessions.js
  (all grew significantly from live reasoning, reload recovery, CSRF fixes etc.)
- New major tier: Jordan-SkyLF (live streaming + session recovery)
- New feature tier: gabogabucho, bergeouss, ccqqlo, betamod, TaraTheStar,
  thadreber-web, deboste
- New bug/security tier: Hinotoi-agent, lawrencel1ng, lx3133584, DelightRun,
  shaoxianbilly, huangzt, kcclaw001, mbac, andrewy-wizard, mmartial,
  vCillusion, carlytwozero, mangodxd

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:47:28 -07:00
nesquena-hermes
7f69155904 docs: v0.50.21 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:26:48 -07:00
nesquena-hermes
54181d1a07 fix: durable inflight reload snapshots via localStorage (#367)
* fix: persist durable inflight reload snapshots

* fix: remove duplicate loadInflightState stub, update CHANGELOG test count

The stub added in the previous review branch is superseded by the author's
real localStorage-backed implementation in the cherry-picked commit 36051c0.
Remove the duplicate. Update CHANGELOG to 961 tests and document the durable
inflight state feature.

---------

Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:25:31 -07:00
nesquena-hermes
9542639a90 fix: live reasoning, tool progress, in-flight session recovery (#367)
* fix: preserve live session output across chat switches

(cherry picked from commit 401e3b643d25e8dad8c06883b478b3c3073f07a5)

* fix: preserve todo state after session reload

(cherry picked from commit 7ee093ba19978af23b79148df2f2347e2f1e5bde)

* fix: preserve live assistant anchor across rerenders

* fix: stream live reasoning and tool progress

* fix: recover inflight session state after reload

* fix: add loadInflightState stub + CHANGELOG v0.50.21

- static/ui.js: add loadInflightState() function (currently returns null —
  the typeof guard in sessions.js means reload recovery works via the
  else-path attachLiveStream call; this stub satisfies the guard cleanly
  and documents the extension point for future localStorage-backed state)
- CHANGELOG.md: v0.50.21 entry; 960 tests (up from 949)

---------

Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:18:15 -07:00
nesquena-hermes
bcdd7ed3f3 docs: v0.50.20 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 15:53:52 -07:00
nesquena-hermes
7a80e73eb2 fix: silent agent errors, stale model list, live model fetching (#377)
* fix: silent errors, stale models, live model fetching (#373, #374, #375)

- api/streaming.py: detect empty agent response (_assistant_added check),
  emit apperror(type='no_response' or 'auth_mismatch') instead of silent done
- api/streaming.py: add _token_sent flag so guard works for streaming agents
- static/messages.js: done handler belt-and-suspenders guard for zero replies
- static/messages.js: apperror handler labels 'no_response' type distinctly

- api/config.py: remove gpt-4o and o3 from _FALLBACK_MODELS and
  _PROVIDER_MODELS['openai'] (superseded by gpt-5.4-mini and o4-mini)

- api/routes.py: new /api/models/live?provider= endpoint, fetches /v1/models
  from provider API with B310 scheme check + SSRF guard
- static/ui.js: _fetchLiveModels() background fetch after static list loads,
  appends new models to dropdown, caches per session, skips unsupported providers

Other:
- tests/test_issues_373_374_375.py: 25 new structural tests
- tests/test_regressions.py: extend done-handler window 1500->2500 chars
- CHANGELOG.md: v0.50.19 entry; 947 tests (up from 922)

* fix: SSRF hostname bypass + auth detection operator precedence

1. routes.py: SSRF guard used substring matching (any(k in hostname))
   which allows bypass via hostnames like evil-ollama.attacker.com.
   Changed to exact hostname matching against a fixed set of known
   local hostnames (localhost, 127.0.0.1, 0.0.0.0, ::1).

2. streaming.py: _is_auth detection had a Python operator precedence
   bug on the ternary expression. The line:
     'AuthenticationError' in type(...).__name__ if _last_err else False
   parsed as the ternary absorbing the rest of the or-chain when
   _last_err was falsy. Fixed to: (_last_err and 'AuthenticationError' in ...)

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

* docs: fix v0.50.20 CHANGELOG version number and test count (949 tests)

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:52:35 -07:00
nesquena-hermes
78de40e015 docs: v0.50.19 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 15:44:19 -07:00
nesquena-hermes
00eb13b316 fix: unicode filenames in Content-Disposition headers (#378)
* Fix unicode filenames in file download headers

* docs: v0.50.19 CHANGELOG entry for unicode filename fix (PR #378)

* docs: fix test count in v0.50.19 CHANGELOG (924 not 926)

---------

Co-authored-by: shaoxianbilly <40623436+shaoxianbilly@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 15:43:01 -07:00
nesquena-hermes
a71047bbc3 docs: v0.50.18 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 14:38:21 -07:00
nesquena-hermes
68426124c5 fix: recover from invalid default workspace paths (#366)
* fix: recover from bad default workspace paths

(cherry picked from commit 789d7537a325d1c7d3aa03c387918dddd2d0897d)

* fix: recover from invalid default workspace paths — 7 tests, CHANGELOG (#366)

- tests/test_default_workspace_fallback.py: 5 additional tests (dedup,
  RuntimeError, env var priority, mkdir on missing dir, unwritable path)
- CHANGELOG.md: v0.50.18 entry; 922 tests (up from 915)

---------

Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 14:28:24 -07:00
nesquena-hermes
4c8042ea00 docs: v0.50.17 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 12:38:00 -07:00
nesquena-hermes
a6484f69a8 fix: Docker uv pre-install at build time + workspace permissions (#365)
* fix: pre-install uv in Docker image + fix workspace dir permissions (#357)

Two fixes for Docker startup reliability:

1. Install uv at build time in the Dockerfile so the container works
   without internet access at runtime. The init script now skips the
   download when uv is already on PATH.

2. Use sudo mkdir/chown for the workspace directory, matching the
   pattern used for /app. Docker auto-creates bind-mount directories
   as root, leaving them unwritable by the hermeswebui user.

Fixes #357

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

* fix: Docker uv pre-install as root to /usr/local/bin + tests + CHANGELOG

Dockerfile: install uv as root with UV_INSTALL_DIR=/usr/local/bin so it
lands in /usr/local/bin (system PATH) rather than /home/hermeswebuitoo/.local/bin
which the hermeswebui runtime user can't see.

tests/test_issue357.py: 15 structural tests covering Dockerfile uv build-time
install (system-wide, as root, before COPY), init script skip-if-present
logic, and workspace sudo mkdir/chown.

CHANGELOG.md: v0.50.17 entry; 915 tests (up from 900)

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:36:11 -07:00
nesquena-hermes
f13f753de8 docs: v0.50.16 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 12:24:35 -07:00
nesquena-hermes
f948baceb6 fix: CSRF check fails behind reverse proxy on non-standard ports (#360)
* fix: CSRF check fails behind reverse proxy on non-standard ports

When serving behind a reverse proxy (e.g. Nginx Proxy Manager) on a
non-standard port like 8000, the browser sends
`Origin: https://example.com:8000` but the proxy forwards `Host: example.com`
(without the port). The existing CSRF check compared these as raw strings,
causing all POST requests to be rejected with 403.

This commit:
- Adds `_normalize_host_port()` to properly parse host:port pairs (incl. IPv6)
- Adds `_ports_match()` that treats absent port as equivalent to 80/443
- Adds `HERMES_WEBUI_ALLOWED_ORIGINS` env var for explicitly trusting origins
  when port normalization alone isn't sufficient (e.g. port 8000)
- Adds unit tests covering port normalization, allowlist, and rejection cases

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

* fix: CSRF port normalization — scheme-aware, allowlist validation, 29 tests (#360)

api/routes.py:
- _normalize_host_port(): parse host:port including IPv6 bracket notation
- _ports_match(scheme, origin_port, allowed_port): scheme-aware — http absent=:80,
  https absent=:443; prevents cross-protocol false match (http://host:80 no
  longer passes for https://host:443 server)
- _allowed_public_origins(): parse HERMES_WEBUI_ALLOWED_ORIGINS env var;
  warn and skip entries missing scheme prefix
- _check_csrf(): extract origin scheme, pass to _ports_match; add origin_scheme

tests/test_sprint29.py: 29 new tests (5 from PR + 24 added in review)
- Unit tests for _normalize_host_port and _ports_match helpers
- Cross-protocol rejection (http vs https default ports)
- Explicit :80 / :443 same-origin pass
- Non-default port rejection
- Bug scenario with/without allowlist
- Comma-separated allowlist
- No-scheme allowlist warning
- Trailing-slash normalization

CHANGELOG.md: v0.50.16 entry; 900 tests total (up from 871)

---------

Co-authored-by: liangxu.5 <liangxu.5@bytedance.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 12:23:16 -07:00
nesquena-hermes
5bdeb93559 docs: v0.50.15 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 11:43:17 -07:00
nesquena-hermes
d0e08fee88 feat: KaTeX math rendering for LaTeX in chat + workspace previews (#352)
* feat: KaTeX math rendering for $..$ and $$..$$ in chat and previews (fixes #347)

- Stash math delimiters before markdown pipeline, restore as .katex-block/.katex-inline elements
- KaTeX JS lazy-loaded from CDN on first math block (mirrors mermaid pattern)
- KaTeX CSS loaded eagerly in <head> to prevent layout shift
- SRI hashes on both CDN tags
- throwOnError:false — bad LaTeX degrades to code span
- Supports $$, $, \\(...\\), \\[...\\] delimiters
- 18 new tests, 831/831 passing

* fix: remove invalid \' escape sequences in math stash lines

Lines 311, 314, 316, 317 had \' (backslash-quote) instead of plain '
in the arrow function bodies. This is a JS syntax error — node --check
fails with 'Invalid or unexpected token'. Likely caused by a
serialization artifact during code generation.

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

* fix: swap stash order (fence before math) to protect code spans; add renderKatexBlocks to workspace preview

- static/ui.js: fence_stash now runs BEFORE math_stash so dollar signs
  inside backtick code spans are not extracted as math. Previously
  `$x$` would render as KaTeX inside a <code> tag instead of
  showing the literal string $x$.
- static/workspace.js: add requestAnimationFrame(renderKatexBlocks)
  after markdown preview renders so math works in workspace file
  previews, not only in chat messages.

* feat: KaTeX math rendering + stash order fix + workspace wiring (#352)

- tests/test_issue347.py: 11 new tests (29 total) covering fence-before-math
  ordering, workspace.js renderKatexBlocks call, stash token distinctness,
  false-positive prevention, safe-tags boundary check
- CHANGELOG.md: v0.50.15 entry; 870 tests total (up from 841)

* fix: use literal null byte (\x00M) in math stash token — matches restore regex

The original PR's second commit (fix: remove invalid \' escapes) accidentally
doubled the backslash in the math stash tokens: '\\x00M' is a 5-char string
(backslash + x + 0 + 0 + M) but the restore regex /\x00M/ expects a null byte.
Result: $...$ in messages produced visible \x00M0\x00 tokens instead of
KaTeX spans.

Changed all 4 math stash return statements to use '\x00M' (single backslash =
null byte, same convention as fence_stash's '\x00F').

Also updates test_stash_tokens_distinct to check for the correct pattern.

* fix: add null-byte token test; update CHANGELOG to v0.50.15 with fixes

- tests/test_issue347.py: add test_math_stash_token_uses_single_backslash_null_byte
  to catch the \\x00M double-backslash regression; 30 tests total (up from 29)
- CHANGELOG.md: v0.50.15 entry documents all fixes including the token bug
  and workspace preview wiring; 871 tests total

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:40:15 -07:00
nesquena-hermes
dd17a0e9b7 security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
Some checks failed
Release & Docker / release (push) Has been cancelled
* security: fix bandit security issues (B310, B324)

- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists

* fix: handle ConnectionResetError gracefully and add debug logging

- Add QuietHTTPServer class to suppress noisy connection reset errors
  caused by clients disconnecting abruptly (fixes log spam from
  'ConnectionResetError: [Errno 54] Connection reset by peer')

- Replace silent 'pass' statements with logger.debug() calls across
  api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
  and api/onboarding.py for better observability during troubleshooting

- All tests pass (25 passed in test_regressions.py)

* chore: add debug logging to profiles and routes modules

- Replace silent 'pass' statements with logger.debug() calls in
  api/profiles.py for better error visibility during profile switching
  and module patching

- Add logger initialization to api/routes.py

* security: fix B110 bare except/pass issues (bandit security scan)

- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully

* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)

- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)

---------

Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 11:11:56 -07:00
223 changed files with 45813 additions and 3808 deletions

View File

@@ -52,5 +52,6 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: HERMES_VERSION=${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max

17
.gitignore vendored
View File

@@ -16,15 +16,26 @@ archive/
.env
.env.*
!.env.example
.claude/*
.claude/
CLAUDE.md
AGENTS.md
.cursorrules
.windsurfrules
.aider*
copilot-instructions.md
# Generated screenshots and transient artifacts
screenshot-*.png
full-UI.png
# Version file written by Docker/CI build — generated, never committed
api/_version.py
# OS files
.DS_Store
Thumbs.db
# Local reference clones — never committed
docs/
# Local reference clones — never committed (except tracked design/UI-UX reference pages)
docs/*
!docs/ui-ux/
!docs/ui-ux/**

View File

@@ -1,53 +0,0 @@
# Web UI MVP Instructions
Canonical source: <repo>/
Symlink (for imports): <agent-dir>/webui-mvp -> <repo>
Runtime state: ~/.hermes/webui-mvp/sessions/
Purpose:
- Claude-style web UI for Hermes. Chat, workspace file browser, cron/skills/memory viewers.
Start server:
cd <agent-dir>
nohup venv/bin/python <repo>/server.py > /tmp/webui-mvp.log 2>&1 &
# OR: <repo>/start.sh
Run tests:
cd <agent-dir>
venv/bin/python -m pytest <repo>/tests/ -v
Health check: curl http://127.0.0.1:8787/health
Logs: tail -f /tmp/webui-mvp.log
SSH tunnel from Mac: ssh -N -L 8787:127.0.0.1:8787 <user>@<your-server>
Living documents (always update after a sprint):
<repo>/ROADMAP.md
<repo>/ARCHITECTURE.md
<repo>/TESTING.md
Sprint process skill: webui-sprint-loop
# Workspace Convention (Web UI Sessions)
When running as an agent invoked from the web UI, each user message is prefixed with:
[Workspace: /absolute/path/to/workspace]
This tag is the single authoritative source of the active workspace. It reflects
whichever workspace the user has selected in the UI at the moment they sent that message.
It updates on every message, so if the user switches workspaces mid-session, the very
next message will carry the new path. Always use the value from the most recent tag.
This tag overrides any prior workspace mentioned in the system prompt, memory, or
conversation history. Never infer or fall back to a hardcoded path like
~/workspace when this tag is present.
Apply it as the default working directory for ALL file operations:
- write_file: resolve relative paths against this workspace
- read_file / search_files: resolve paths relative to this workspace
- terminal workdir: set to this path unless the user explicitly says otherwise
- patch: resolve file paths relative to this workspace
If no [Workspace: ...] tag is present (e.g., CLI sessions), fall back to
~/workspace as the default.

View File

@@ -7,6 +7,11 @@
>
> Keep this document updated as architecture changes are made.
> Current shipped build: `v0.50.36-local.1` (April 16, 2026).
> Baseline: upstream `nesquena/hermes-webui` `v0.50.36`.
> Intentional local delta: first-time password enablement from Settings immediately issues a `hermes_session` cookie so the current browser remains signed in. The previous `Assistant Reply Language` customization has been removed, legacy `assistant_language` settings are filtered out on load/save, the workspace panel closed/open state is preloaded via a `documentElement` dataset marker before `style.css` paints to avoid a first-load desktop flash, transcript disclosure cards now animate caret rotation and body expansion with transitionable `max-height`/`opacity` states instead of `display:none/block`, and thinking cards now share the same rounded bordered card chrome as tool cards while keeping their gold palette.
> Automated coverage: 1353 tests collected (`pytest tests/ --collect-only -q`).
---
## 1. Overview and Purpose
@@ -18,11 +23,21 @@ and a demand-driven right panel used for workspace browsing and preview surfaces
The right panel is closed by default on desktop and opens only when it is actively
being used for browsing or previewing content.
To prevent a visible first-paint mismatch on refresh, `static/index.html` preloads the
saved workspace panel state into `document.documentElement.dataset.workspacePanel`
before the main stylesheet loads. Desktop CSS honors that preload marker immediately,
and `static/boot.js` keeps the dataset synchronized with the runtime panel state machine.
The design philosophy is deliberately minimal. There is no build step, no bundler, no
frontend framework. The Python server is split into a routing shell (server.py) and
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.
This makes the code easy to modify from a terminal or by an agent.
For the current local build, the codebase is intentionally as close to upstream as possible:
the app now tracks upstream `v0.50.36`, keeps the password-session continuity patch in the
settings/onboarding flow, and does not carry forward the prior reply-language preference
feature.
Hermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.
Instead, the footer exposes a single "Hermes WebUI" launch button that opens one tabbed
control-center modal for global preferences, conversation import/export, and clear-conversation
@@ -63,7 +78,7 @@ actions. The topbar remains focused on conversation context and the workspace/fi
panels.js Cron, skills, memory, workspace, profiles, todo, settings (~974 lines)
commands.js Slash command registry, parser, autocomplete dropdown (~156 lines)
onboarding.js First-run wizard overlay, provider setup flow, and settings/workspace orchestration.
boot.js Event wiring, mobile nav, voice input, boot IIFE (~338 lines)
boot.js Event wiring, mobile sidebar/workspace nav, voice input, boot IIFE (~338 lines)
tests/
conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines)
test_sprint{1-20b}.py Feature tests per sprint (21 files, 415 test functions)
@@ -1614,3 +1629,19 @@ and #rightpanelResize. On mousemove: computes delta and clamps to min/max. On mo
saves width to localStorage. Widths restored at boot via localStorage.getItem().
CSS: .resize-handle with position:absolute, width:5px, cursor:col-resize.
body.resizing added during drag to suppress text selection.
## Workspace path trust levels
`api/workspace.py` has two distinct trust functions — do not collapse them:
**`validate_workspace_to_add(path)`** — used by `/api/workspaces/add` (explicit user registration).
Permissive: blocks only non-existent, non-directory, and system root paths. The user is
consciously registering an external path (e.g. `/mnt/d/Projects` in WSL), so we trust intent.
**`resolve_trusted_workspace(path)`** — used for actual file read/write operations inside
an existing workspace. Strict: path must be under home, in the saved workspace list, or under
`BOOT_DEFAULT_WORKSPACE`. Prevents path traversal and unauthorized file access.
The distinction matters because add uses permissive validation to avoid the circular
dependency: you cannot get a path into the saved list if you need the saved list to add it.

12
BUGS.md
View File

@@ -10,6 +10,18 @@ This file tracks UI bugs and polish items. Fixed items are kept for reference.
---
## Known Limitations
- **Two-container Docker setup: tools run in WebUI container** — In the two-container setup (hermes-agent + hermes-webui as separate containers), WebUI-initiated agent sessions run tools in the WebUI container, not the agent container. This is a known architectural constraint. Workaround: use the combined single-image approach, or initiate sessions via the CLI in the agent container. (#681)
- **Image-in-chat vs. saved-to-workspace mismatch** — When the agent displays an inline image (from a URL) and the user asks it to save that image, the agent issues a fresh download which may return a different file if the source URL is CDN-rotated or parameterized. The WebUI correctly renders whatever URL the agent provides. Fix requires agent-side URL caching. (#641)
- **MCP tools not available in WebUI sessions** — MCP servers must be configured in the active profile's config.yaml under mcp_servers:. If MCP tools are not appearing, check that the profile is correct and the MCP server process is reachable from inside the WebUI container. (#628)
- **os.environ race condition in concurrent sessions** — Concurrent agent sessions share process-level os.environ for TERMINAL_CWD, HERMES_SESSION_KEY, and HERMES_HOME. _ENV_LOCK serializes mutations but does not fully isolate env vars during agent execution. Upstream fix pending in hermes-agent. (#195)
---
## Fixed
### ~~Session title truncation / hover actions~~ -- Fixed (Sprint 16)

File diff suppressed because it is too large Load Diff

171
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,171 @@
# Contributing to Hermes WebUI
Thanks for contributing.
Hermes WebUI is intentionally simple to work on: Python on the server, vanilla JS in the browser, no build step, no bundler, no frontend framework. The best pull requests preserve that simplicity while solving a real problem cleanly.
## Two Paths to a Strong Pull Request
### Path 1: Small, Focused Changes
This is the fastest path to review and merge.
- Fix one clear bug or add one tightly scoped improvement
- Touch the fewest files you can
- Avoid drive-by refactors mixed into functional changes
- Run the relevant tests locally before opening the PR
- Keep the PR description concise and specific
These are the changes that are easiest to review and safest to merge quickly.
### Path 2: Bigger Changes
If you want to change architecture, reshape a workflow, add a substantial UI feature, or alter core behavior, align on direction first.
- Open an issue, start a discussion, or open a draft PR early
- Explain the problem you are solving, not just the implementation you want
- Call out tradeoffs, migration risk, and any alternatives you considered
- Keep the final PR easy to review by separating unrelated work
Large changes are welcome, but surprise rewrites are hard to review well.
## What We Expect in Every PR
### 1. One Logical Change Per PR
Keep each PR focused. A small related group of fixes is fine. A bug fix plus a CSS cleanup plus a refactor plus a docs rewrite is not.
### 2. Local Verification
Run the test suite locally:
```bash
pytest tests/ -v --timeout=60
```
CI also runs this suite on Python `3.11`, `3.12`, and `3.13`.
If your change affects browser behavior, also run the relevant manual checks from [TESTING.md](TESTING.md).
### 3. Clear PR Description
There is currently no PR template in this repo, so include the important sections yourself:
- Thinking Path
- What Changed
- Why It Matters
- Verification
- Risks / Follow-ups
- Model Used
If the change is user-visible, include screenshots or a short video.
For UI or UX changes, before/after images are required. PRs that change the interface or interaction flow without before/after images will likely be ignored, or closed in a regular maintainer sweep without review.
### 4. AI Usage Disclosure
If AI helped produce the change, say so in the PR description.
Include:
- Provider
- Exact model name or ID
- Any notable mode or tool use that mattered
If no AI was used, write: `None — human-authored`.
### 5. Keep the Docs Honest
If your change alters behavior, architecture, testing, setup, or user-facing workflows, update the relevant docs in the same PR.
Common files:
- [README.md](README.md) for setup, usage, and contributor-facing commands
- [ROADMAP.md](ROADMAP.md) for shipped features and sprint history
- [ARCHITECTURE.md](ARCHITECTURE.md) for implementation details and design constraints
- [TESTING.md](TESTING.md) for manual and automated verification guidance
- [CHANGELOG.md](CHANGELOG.md) when maintainers want release-note-ready entries
## Project-Specific Guidelines
### Preserve the Design Constraints
Hermes WebUI is deliberately:
- No build step
- No bundler
- No frontend framework
- Easy to modify from a terminal
Do not introduce new infrastructure or dependencies unless the gain is clear and the tradeoff is justified.
### Match the Existing Shape of the Codebase
- Server logic belongs in `api/` with `server.py` staying thin
- Frontend behavior belongs in the existing `static/*.js` modules
- Prefer extending current patterns over introducing parallel abstractions
- Keep changes legible to future contributors working directly from the repo in a terminal
### Be Careful With User-Facing Changes
This project is heavily UI-driven. If you change interaction flows, session behavior, workspace browsing, onboarding, or mobile layouts:
- test the happy path
- test reload behavior where relevant
- test narrow/mobile layouts where relevant
- include before/after images in the PR
### Security and Safety Matter
This app can expose workspace contents, run agent actions, and optionally sit behind a reverse proxy or Docker deployment. Treat auth, path handling, uploads, streaming, and environment handling as high-risk areas.
If your PR touches security-sensitive behavior, say so explicitly in the PR description and explain how you verified it.
## Writing a Good PR Message
Start with a short Thinking Path that explains the chain from project goal to the specific fix.
Example:
> - Hermes WebUI aims for near 1:1 parity with the Hermes CLI in a browser
> - Long-running chat turns rely on SSE streaming and session recovery
> - Reloading during an in-flight turn can leave the UI in an inconsistent state
> - The bug was that recovered sessions restored messages but not the live stream state
> - This PR fixes the recovery path so in-flight turns reconnect cleanly after reload
> - The benefit is that users can refresh or reconnect without losing visibility into active work
Another example:
> - Hermes WebUI is intentionally a simple Python + vanilla JS application
> - The right panel is used for workspace browsing and previews
> - On mobile, panel state changes need to be obvious and touch-friendly
> - The existing close affordance was inconsistent with the bottom-nav flow
> - This PR fixes the mobile panel close behavior and aligns it with the current navigation model
> - The result is fewer dead-end UI states on phones
After that, cover:
- what you changed
- why you changed it
- how you verified it
- what risks remain
## Review Tips
Want the smoothest review?
- Keep diffs tight
- Name things clearly
- Avoid unnecessary rewrites
- Add short comments only where the code would otherwise be hard to follow
- Respond directly to review feedback and update the PR description if the scope changes
## Development References
- [README.md](README.md)
- [ARCHITECTURE.md](ARCHITECTURE.md)
- [TESTING.md](TESTING.md)
- [ROADMAP.md](ROADMAP.md)
- [SPRINTS.md](SPRINTS.md)
Questions are best raised early, before a large change is finished.

View File

@@ -24,6 +24,7 @@ RUN apt-get update -y --fix-missing --no-install-recommends \
sudo \
curl \
rsync \
openssh-client \
&& apt-get upgrade -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -67,9 +68,23 @@ RUN touch /.within_container
RUN rm -rf /var/lib/apt/lists/* /etc/apt/apt.conf.d/01proxy \
&& apt-get clean
USER root
# Pre-install uv system-wide so the container doesn't need internet access at runtime.
# Installing as root places uv in /usr/local/bin, available to all users.
# The init script will skip the download when uv is already on PATH.
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
USER hermeswebuitoo
COPY . /apptoo
COPY --chown=hermeswebuitoo:hermeswebuitoo . /apptoo
# Bake the git version tag into the image so the settings badge works even
# when .git is not present (it is excluded by .dockerignore).
# CI passes: --build-arg HERMES_VERSION=$(git describe --tags --always)
# Local builds that omit the arg get "unknown" as the fallback.
ARG HERMES_VERSION=unknown
RUN echo "__version__ = '${HERMES_VERSION}'" > /apptoo/api/_version.py
# Default to binding all interfaces (required for container networking)
ENV HERMES_WEBUI_HOST=0.0.0.0

185
README.md
View File

@@ -13,12 +13,12 @@ the **composer footer** — always visible while composing. A circular context r
shows token usage at a glance. All settings and session tools are in the
**Hermes Control Center** (launcher at the sidebar bottom).
<img alt="Hermes Web UI — three-panel layout" width="1417" height="867" alt="image" src="https://github.com/user-attachments/assets/51adff98-53ee-4800-8508-78b6c34dd3dc" />
<img width="2448" height="1748" alt="Hermes Web UI — three-panel layout" src="https://github.com/user-attachments/assets/6bf8af4c-209d-441e-8b92-6515d7a0c369" />
<table>
<tr>
<td width="50%" align="center">
<img alt="Light mode with full profile support" src="https://github.com/user-attachments/assets/9b68142f-d974-4493-a8d1-fd73e622c7fd" />
<img width="2940" height="1848" alt="Light mode with full profile support" src="https://github.com/user-attachments/assets/4ef3a59c-7a66-4705-b4e7-cb9148fe4c47" />
<br /><sub>Light mode with full profile support</sub>
</td>
<td width="50%" align="center">
@@ -189,6 +189,13 @@ This starts both containers with shared volumes:
- **`hermes-agent-src`** — the agent's source code, mounted into the WebUI
container so it can install the agent's Python dependencies at startup
> **Volume type:** The compose files use named Docker volumes by default.
> If you prefer bind mounts to an existing directory (e.g. for sharing state
> with an agent container you already run), both containers must mount the
> same host path — the agent writes to `/root/.hermes`, the WebUI reads from
> `/home/hermeswebui/.hermes`. See `docker-compose.two-container.yml` for
> a bind-mount example.
The WebUI's init script automatically installs hermes-agent and all its
dependencies (openai, anthropic, etc.) into its own Python environment on
first boot. Subsequent restarts reuse the installed packages.
@@ -200,6 +207,75 @@ first boot. Subsequent restarts reuse the installed packages.
See `docker-compose.two-container.yml` for the full configuration.
### Running alongside hermes-dashboard (three-container setup)
To run the Hermes Agent, Hermes Dashboard, and the WebUI together on a
shared volume, use the three-container Compose file:
```bash
docker compose -f docker-compose.three-container.yml up -d
```
This brings up:
- **`hermes-agent`** — gateway API on port 8642
- **`hermes-dashboard`** — monitoring UI on port 9119
- **`hermes-webui`** — browser chat interface on port 8787
All three services share the same `hermes-home` named volume so config,
sessions, skills, and memory are consistent across all surfaces.
#### Why UIDs must match
The `hermes-home` volume is a bind-mount in practice — all three containers
write to the same filesystem tree under `~/.hermes`. If the containers run
as different UIDs, whichever container creates a file first becomes its
owner, and the others hit `PermissionError` on subsequent writes.
The fix is to make all containers run as **your host user's UID and GID**.
#### Variable name asymmetry
> ⚠️ **The two image families use different environment variable names** for
> the UID/GID setting:
>
> | Image | Variable |
> |---|---|
> | `nousresearch/hermes-agent` (agent + dashboard) | `HERMES_UID` / `HERMES_GID` |
> | `ghcr.io/nesquena/hermes-webui` | `WANTED_UID` / `WANTED_GID` |
>
> You must set **both pairs** when using a `.env` file.
#### Recommended setup
For a standard Linux user (UID ≥ 1000):
```bash
# Create a .env file with your host UID/GID
echo "UID=$(id -u)" >> .env
echo "GID=$(id -g)" >> .env
# hermes-agent / hermes-dashboard
echo "HERMES_UID=$(id -u)" >> .env
echo "HERMES_GID=$(id -g)" >> .env
```
For NAS/Unraid deployments where a fixed service account is preferred, use
`10000:10000` (or your NAS service UID) instead of `$(id -u)`.
If you get `PermissionError` on an **existing** `~/.hermes` directory, run
the one-time ownership fix:
```bash
chown -R $(id -u):$(id -g) ~/.hermes
```
#### Volume mount mode
The dashboard container needs **read-write** access to the shared volume
(it writes session logs and dashboard state). Do **not** add `:ro` to the
`hermes-home` volume in `hermes-dashboard`'s `volumes:` entry.
See `docker-compose.three-container.yml` for the full reference configuration.
---
## What start.sh discovers automatically
@@ -222,6 +298,7 @@ If discovery finds everything, nothing else is required.
export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent
export HERMES_WEBUI_PYTHON=/path/to/python
export HERMES_WEBUI_PORT=9000
export HERMES_WEBUI_AUTO_INSTALL=1 # enable auto-install of agent deps (disabled by default)
./start.sh
```
@@ -277,8 +354,8 @@ WireGuard. Install it on your server and your phone, and they join the same
private network -- no port forwarding, no SSH tunnels, no public exposure.
The Hermes Web UI is fully responsive with a mobile-optimized layout
(hamburger sidebar, bottom navigation bar, touch-friendly controls), so it
works well as a daily-driver agent interface from your phone.
(hamburger sidebar, sidebar top tabs in the drawer, touch-friendly controls),
so it works well as a daily-driver agent interface from your phone.
**Setup:**
@@ -339,8 +416,8 @@ Or using the agent venv explicitly:
```
Tests run against an isolated server on port 8788 with a separate state directory.
Production data and real cron jobs are never touched. Current count: **802 tests**
across 51 test files.
Production data and real cron jobs are never touched. Current count: **1898 tests**
across 53 test files.
---
@@ -436,7 +513,7 @@ across 51 test files.
### Slash commands
- Type `/` in the composer for autocomplete dropdown
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`, `/compact`
- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
- Arrow keys navigate, Tab/Enter select, Escape closes
- Unrecognized commands pass through to the agent
@@ -451,10 +528,10 @@ across 51 test files.
### Mobile responsive
- Hamburger sidebar -- slide-in overlay on mobile (<640px)
- Bottom navigation bar -- 5-tab iOS-style fixed bar
- Sidebar top tabs stay available on mobile; no fixed bottom nav stealing chat height
- Files slide-over panel from right edge
- Touch targets minimum 44px on all interactive elements
- Composer positioned above bottom nav
- Full-height chat/composer on phones without bottom-nav spacing
- Desktop layout completely unchanged
---
@@ -470,25 +547,25 @@ api/
models.py Session model + CRUD + CLI bridge (~377 lines)
onboarding.py First-run onboarding wizard, OAuth provider support (~507 lines)
profiles.py Profile state management, hermes_cli wrapper (~411 lines)
routes.py All GET + POST route handlers (~1996 lines)
routes.py All GET + POST route handlers (~2250 lines)
state_sync.py /insights sync — message_count to state.db (~113 lines)
streaming.py SSE engine, run_agent, cancel support (~545 lines)
streaming.py SSE engine, run_agent, cancel support (~660 lines)
updates.py Self-update check and release notes (~257 lines)
upload.py Multipart parser, file upload handler (~82 lines)
workspace.py File ops, workspace helpers, git detection (~288 lines)
static/
index.html HTML template (~600 lines)
style.css All CSS incl. mobile responsive, themes (~1050 lines)
ui.js DOM helpers, renderMd, tool cards, context indicator (~1496 lines)
ui.js DOM helpers, renderMd, tool cards, context indicator (~1740 lines)
workspace.js File preview, file ops, git badge (~286 lines)
sessions.js Session CRUD, collapsible groups, search (~752 lines)
messages.js send(), SSE handlers, rAF throttle (~487 lines)
sessions.js Session CRUD, collapsible groups, search, reload recovery (~800 lines)
messages.js send(), SSE handlers, live streaming, session recovery (~655 lines)
panels.js Cron, skills, memory, profiles, settings (~1438 lines)
commands.js Slash command autocomplete (~267 lines)
boot.js Mobile nav, voice input, boot IIFE (~524 lines)
tests/
conftest.py Isolated test server (port 8788)
51 test files 802 test functions
61 test files 961 test functions
Dockerfile python:3.12-slim container image
docker-compose.yml Compose with named volume and optional auth
.github/workflows/ CI: multi-arch Docker build + GitHub Release on tag
@@ -524,18 +601,60 @@ Six consecutive security and reliability PRs: session memory leak fix (expired t
**[@DavidSchuchert](https://github.com/DavidSchuchert)** — German translation (PR #190)
Complete German locale (`de`) covering all UI strings, settings labels, commands, and system messages — and in doing so, stress-tested the i18n system and exposed several elements that weren't yet translatable, which got fixed as part of the same PR.
**[@Jordan-SkyLF](https://github.com/Jordan-SkyLF)** — Live streaming, session recovery, workspace fallback (PRs #366, #367)
Three interlocking improvements: workspace fallback resolution so the server recovers gracefully when the configured workspace is deleted or unavailable; live reasoning cards that upgrade the generic thinking spinner to a real-time reasoning display as the model thinks; and durable session state recovery via `localStorage` so in-flight tool cards, partial assistant output, and the live SSE stream all survive a full page reload or session switch.
### Feature contributions
**[@gabogabucho](https://github.com/gabogabucho)** — Spanish locale + onboarding wizard (PRs #275, #285)
Full Spanish (`es`) locale covering all 175 UI strings, plus the one-shot bootstrap onboarding wizard that guides new users through provider setup on first launch — the feature most responsible for new users actually getting started.
**[@bergeouss](https://github.com/bergeouss)** — Real-time gateway session sync (PR #274)
Bridged the gateway session database (Telegram, Discord, Slack, etc.) into the WebUI sidebar with live SSE polling. Gateway sessions now appear alongside WebUI sessions in real time, without any changes to hermes-agent.
**[@ccqqlo](https://github.com/ccqqlo)** — Terminal approval UX + custom model discovery + mobile close button (PRs #224, #225, #238, #333)
A run of focused quality-of-life improvements: terminal tool approval prompts that stay visible long enough to actually be read, restored custom model API key discovery, and the redundant mobile close button fix that had been confusing users on narrow screens.
**[@kevin-ho](https://github.com/kevin-ho)** — OLED theme (PR #168)
Added the 7th built-in theme: pure black backgrounds with warm accents tuned to reduce burn-in risk. Small diff, big impact for anyone on an OLED display.
**[@Bobby9228](https://github.com/Bobby9228)** — Mobile Profiles button (PR #265)
Added the Profiles tab to the mobile bottom navigation bar, making profile switching reachable on phones without digging into the sidebar.
**[@Bobby9228](https://github.com/Bobby9228)** — Mobile Profiles button + Android Chrome fixes (PRs #253, #263, #265)
Added the Profiles entry to the mobile navigation flow, making profile switching reachable on phones, plus a set of Android Chrome-specific fixes for the profile dropdown.
**[@franksong2702](https://github.com/franksong2702)** — Session title guard + breadcrumb nav (PRs #301, #302)
Two clean bug fixes / features: the session title guard that stops `title_from()` from overwriting user-renamed sessions after every turn, and clickable breadcrumb navigation in the workspace file preview panel.
### Bug fix contributions
**[@betamod](https://github.com/betamod)** — Security hardening (PR #171)
A comprehensive security audit PR covering CSRF protection, SSRF guards, XSS escaping improvements, and the env race condition between concurrent agent sessions — foundational security work that shipped in v0.39.0.
**[@TaraTheStar](https://github.com/TaraTheStar)** — Bot name + thinking blocks + login refactor (PRs #132, #176, #181)
Made the assistant display name configurable throughout the UI, added thinking/reasoning block display in chat, and refactored the login page to use template variables instead of inline string replacement.
**[@thadreber-web](https://github.com/thadreber-web)** — CLI session bridge (PR #56)
The original CLI session bridge: reads CLI sessions from the agent's SQLite state store and surfaces them in the WebUI sidebar. This was the first bridge between the CLI and WebUI session worlds.
**[@deboste](https://github.com/deboste)** — Reverse proxy auth + mobile responsive layout + model routing (PRs #3, #4, #5)
Three of the very first community PRs: fixed EventSource/fetch to use the URL origin for reverse proxy setups, corrected model provider routing from config, and added mobile responsive layout with dvh viewport fix. Early foundation work.
### Bug fix and security contributions
**[@Hinotoi-agent](https://github.com/Hinotoi-agent)** — Profile .env secret isolation (PR #351)
Fixed API key leakage between profiles on switch — switching from a profile with `OPENAI_API_KEY` to one without it left the key in the process environment for the duration of the session, effectively leaking credentials. A subtle and important security fix.
**[@lawrencel1ng](https://github.com/lawrencel1ng)** — Bandit security fixes B310/B324/B110 + QuietHTTPServer (PR #354)
Systematic bandit security scan fixes: URL scheme validation before `urlopen`, MD5 `usedforsecurity=False`, and 40+ bare `except: pass` blocks replaced with proper logging — plus `QuietHTTPServer` to stop client-disconnect log spam from SSE streams.
**[@lx3133584](https://github.com/lx3133584)** — CSRF fix for reverse proxy on non-standard ports (PR #360)
Fixed CSRF rejection for deployments behind Nginx Proxy Manager or similar on non-standard ports — a real-world blocker for anyone hosting on a port other than 80/443.
**[@DelightRun](https://github.com/DelightRun)** — session_search fix for WebUI sessions (PR #356)
The `session_search` tool silently returned "Session database not available" in every WebUI session. Tracked down the missing `SessionDB` injection in the streaming path and fixed it.
**[@shaoxianbilly](https://github.com/shaoxianbilly)** — Unicode filename downloads (PR #378)
Fixed `UnicodeEncodeError` crashes when downloading workspace files with Chinese, Japanese, or other non-ASCII names. Implemented proper `Content-Disposition` header with RFC 5987 `filename*=UTF-8''...` encoding.
**[@huangzt](https://github.com/huangzt)** — Cancel interrupts agent (PR #244)
Made the Cancel button actually interrupt the running agent and clean up UI state, rather than just hiding the button while the agent kept running.
**[@tgaalman](https://github.com/tgaalman)** — Thinking card fix (PR #169)
Fixed top-level reasoning fields being missed in the thinking card display — an edge case in how Claude's extended thinking blocks surface in the API response.
@@ -546,6 +665,36 @@ Fixed model routing for slash-prefixed custom provider models, which were being
**[@jeffscottward](https://github.com/jeffscottward)** — Claude Haiku model ID fix (PR #145)
Caught and corrected the Claude Haiku model ID (`3-5``4-5`) immediately after the Anthropic release — the kind of quick community catch that keeps the model dropdown accurate.
**[@kcclaw001](https://github.com/kcclaw001)** — Credential redaction in API responses (PR #243)
Added credential redaction to all API response paths so API keys, tokens, and other secrets in session data or error messages are masked before reaching the browser.
**[@mbac](https://github.com/mbac)** — Phantom "Custom" provider group fix (PR #191)
Removed the phantom "Custom" optgroup that appeared in the model dropdown even when no custom provider was configured — a small but consistently confusing UI noise issue.
**[@andrewy-wizard](https://github.com/andrewy-wizard)** — Chinese localization (PR #177)
Added Simplified Chinese (`zh`) locale to the WebUI. One of the first non-English locales and the most-used non-English locale in the codebase.
**[@mmartial](https://github.com/mmartial)** — Docker UID/GID matching (PR #237)
Added Docker support for running as an arbitrary UID/GID matching the host user, eliminating permission issues with bind-mounted volumes — essential for Docker deployments where the host user isn't UID 1000.
**[@vCillusion](https://github.com/vCillusion)** — pip package resolution fix (PR #76)
Fixed agent dependency resolution to prefer packages from the venv's site-packages over the agent directory itself, preventing shadowing bugs when developing locally.
**[@carlytwozero](https://github.com/carlytwozero)** — API key pass-through for non-Anthropic providers (PR #78)
Fixed `api_key` not being passed to `AIAgent` for non-Anthropic `/anthropic` providers — a quiet regression that silently broke any non-default provider.
**[@mangodxd](https://github.com/mangodxd)** — Type hints cleanup (PR #115)
Added missing type hints across 10 files and corrected 9 inaccurate existing ones — the kind of maintenance work that makes the codebase easier to reason about.
**[@Argonaut790](https://github.com/Argonaut790)** — HTML entity decode + Traditional Chinese locale (PR #239)
Fixed double-escaping of HTML entities in `renderMd()` — LLM output containing `&lt;code&gt;` was being escaped a second time, rendering as literal text instead of the intended markdown. The same PR also completed the Simplified Chinese translation (40+ missing keys) and added a full Traditional Chinese (`zh-Hant`) locale.
**[@indigokarasu](https://github.com/indigokarasu)** — Visual redesign proposal: icon rail + design token system + 7 themes (PR #213)
A CSS-only redesign of the full UI — proper design tokens (`--bg-primary`, `--text-info`, spacing scale), an icon rail sidebar replacing the emoji tab strip, consistent form cards, breadcrumb nav, and 7 built-in themes as custom properties. The PR didn't merge as-is but directly shaped the design language and theme architecture that shipped in v0.50.0.
**[@zenc-cp](https://github.com/zenc-cp)** — Anti-hallucination guard for ReAct loop (PR #133)
Added a streaming token buffer and post-run message scrub to `streaming.py` to detect and strip fake tool execution JSON that weaker models write inline instead of calling tools properly. A three-layer approach: ephemeral anti-hallucination prompt, live token filtering, and session history cleanup. The pattern influenced later streaming.py improvements.
---
Want to contribute? See [ARCHITECTURE.md](ARCHITECTURE.md) for the codebase layout and [TESTING.md](TESTING.md) for how to run the test suite. The best contributions are focused, well-tested, and solve a real problem — exactly what every person on this list did.

View File

@@ -3,9 +3,8 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.49.1 (April 12, 2026) — 700 tests, 700 passing
> Onboarding MVP now writes real Hermes provider config from the Web UI for OpenRouter, Anthropic, OpenAI, and custom OpenAI-compatible endpoints.
> Tests: 700 total (700 passing, 0 failures)
> Last updated: v0.50.185 (April 24, 2026) — 2107 tests collected
> Tests: 2107 collected (`pytest tests/ --collect-only -q`)
> Source: <repo>/
---
@@ -38,7 +37,7 @@
| Sprint 18 | Thinking display + workspace tree | File preview auto-close, thinking/reasoning cards, expandable directory tree (#22) | 318 |
| Sprint 19 | Auth + security hardening | Password auth (off by default), login page, security headers, 20MB body limit (#23) | 328 |
| Sprint 20 | Voice input + send button | Voice input (Web Speech API), send button icon-circle with pop-in animation | 415 |
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, bottom nav, files slide-over, Docker support (#21, #7) | 415 |
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, mobile nav, files slide-over, Docker support (#21, #7) | 415 |
| Sprint 22 | Multi-profile support | Profile picker, management panel, seamless switching, per-session tracking (#28) | 415 |
| Sprint 23 | Agentic transparency | Token/cost display, subagent cards, skill picker in cron, skill linked files, workspace tree persistence, timestamp fixes | 424 |
| v0.44.0 patch | Fix batch: approval card, login CSP, update diagnostics, Lucide icons | PRs #221 #225 #226 #227 #228 | 579 |
@@ -49,7 +48,7 @@
| v0.48.0 | Gateway session sync | Real-time Telegram/Discord/Slack sessions in sidebar via SSE + DB polling (#274 @bergeouss); +10 tests | 658 |
| v0.48.1 | Table inline formatting | `inlineMd()` in table cells — **bold**, *italic*, `code`, links render correctly (PR #278); 0 new tests | 658 |
| v0.48.2 | Provider mismatch warning | Toast warning + auth_mismatch error type for provider/model mismatches (#283, fixes #266); +21 tests | 679 |
| v0.49.1 | Docker docs + mobile Profiles button | Two-container Docker compose (#291/#288); Profiles button in mobile bottom nav with mobileSwitchPanel, data-panel, correct SVG size and position (#297/#265 @gabogabucho); +3 tests | 700 |
| v0.49.1 | Docker docs + mobile Profiles button | Two-container Docker compose (#291/#288); Profiles added to the mobile navigation flow with correct panel wiring and SVG sizing (#297/#265 @gabogabucho); +3 tests | 700 |
| v0.49.0 | First-run onboarding wizard + self-update hardening | One-shot bootstrap + guided setup wizard; provider config persisted to config.yaml + .env; OpenRouter/Anthropic/OpenAI/Custom; wizard hidden after completion (#285); self-update stderr/split-ref/conflict fixes (#287); skip flaky redaction test (#289); +18 tests | 697 |
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
@@ -61,6 +60,32 @@
| v0.36v0.37 | Model routing, personality config, tool card reload, duplicate model fixes | Model routing by provider prefix, personality via config.yaml, tool cards reload on page refresh | 466 |
| v0.38.0v0.38.6 | Model selector, custom endpoints, OLED theme, reasoning display, insights sync | Custom endpoint URL fix, OLED theme, top-level reasoning field fix, message_count sync to state.db | 466 |
| v0.39.0 | Security hardening (Sprint 29) | CSRF, PBKDF2, rate limiting, session ID validation, SSRF, ENV_LOCK, XSS, HMAC, skills traversal, secure cookie, error sanitization, startup warning | 499 |
| v0.40v0.44.2 | Approval card + Lucide icons + sprint auth | Approval prompt surfaced in UI, emoji icons → Lucide SVG, login CSP inline fix, update diagnostics | 579 |
| v0.45v0.46 | Custom endpoints + security + i18n + cancel | Custom endpoint Base URL + API key on profile create, credential redaction (PR #243), Docker UID/GID (PR #237), HTML entity decode + zh/zh-Hant i18n, cancel interrupts agent | 624 |
| v0.47v0.47.1 | Dialogs + session menu + skills + mobile QA + Spanish | Shared app dialogs, session ⋯ menu, /skills command, mobile QA suite, Android Chrome fixes, Spanish locale (@gabogabucho) | 648 |
| v0.48v0.48.2 | Gateway session sync + table formatting + provider warnings | Real-time Telegram/Discord/Slack sessions in sidebar (@bergeouss), inlineMd() in table cells, provider/model mismatch toast | 679 |
| v0.49v0.49.1 | Onboarding wizard + Docker two-container | One-shot bootstrap + guided setup wizard, OpenRouter/Anthropic/OpenAI/Custom provider config, two-container Docker compose, mobile Profiles button | 700 |
| v0.50.0 | v0.50.0 UI overhaul (Sprint 34) | Composer-centric controls, Hermes Control Center modal, workspace panel state machine, collapsible date groups, rAF streaming throttle, context ring indicator (@aronprins) | 742 |
| v0.50.5v0.50.10 | Think-tag edge cases + onboarding hardening + mobile fixes | MiniMax M2.5 leading-whitespace think-tag fix, skip-onboarding env var, OAuth provider path, Docker bridge networks fix, model dropdown dedup, title auto-generation fix, mobile close button | 802 |
| v0.50.11v0.50.12 | Chat table styles + URL autolink + profile env isolation | .msg-body table borders, plain URL auto-linking, profile .env secret isolation on switch (prevents API key leakage across profiles, @Hinotoi-agent) | 815 |
| v0.50.13v0.50.15 | session_search + security sweep + KaTeX math | SessionDB injection for session_search in WebUI (@DelightRun), bandit B310/B324/B110 + QuietHTTPServer (@lawrencel1ng), KaTeX math rendering with fence-before-math fix | 871 |
| v0.50.16v0.50.17 | CSRF reverse proxy + Docker uv pre-install | Scheme-aware CSRF port normalization for non-standard ports (@lx3133584), Docker uv pre-installed at build time as root (fixes air-gapped startup, @mmartial-pattern) | 900 |
| v0.50.18v0.50.19 | Workspace fallback + Unicode filenames | Cascading workspace path recovery (@Jordan-SkyLF), Unicode Content-Disposition headers with RFC 5987 filename* (@shaoxianbilly), silent auth error surfacing, stale model cleanup | 924 |
| v0.50.20v0.50.21 | Silent errors + live model fetching + durable streaming recovery | apperror on empty agent response, /api/models/live endpoint with SSRF guard, live reasoning cards, tool_complete SSE events, SESSION_QUEUES, localStorage reload recovery (@Jordan-SkyLF) | 961 |
| v0.50.22v0.50.36-local.1 | Upstream sync + minimal local patch retention | Synced to upstream `v0.50.36`; retained first-password session continuity in Settings/onboarding; removed local Assistant Reply Language enhancement; added legacy settings cleanup regression coverage | 1059 |
| v0.50.37v0.50.40 | Sprint 40 — rendering fixes + KaTeX CSP + MEDIA images | Think-tag edge cases, renderMd link double-linking fix, MEDIA: inline image rendering, KaTeX CSP font-src fix | 1117 |
| v0.50.41v0.50.43 | Sprint 41/42 — context ring, session polish, renderMd hardening | Context indicator live usage, session display fixes, renderMd bold+code stash, outer link pass ordering, _ob_stash, autolink double-link fixes (@multiple contributors) | 1150 |
| v0.50.44 | Renderer formatting bug fixes (#486, #487) | CSS: inline code sizing in table cells; JS: markdown image syntax ![alt](url) → <img> in renderMd + inlineMd; _img_stash for autolink protection | 1195 |
| v0.50.45v0.50.100 | Upstream sync + contributor sprint | Sidebar declutter, SKIP_ONBOARDING, runtime route details, subpath mount, bug batch (light theme/panel/model cache/Docker), Docker UID/GID auto-detect, chat transcript redesign, favicon SVG+PNG+ICO, Docker UID-mismatch crash fix, auto-title markdown strip | 1777 |
| v0.50.101v0.50.139 | Contributor sprint wave | Custom providers, Russian locale, collapsed timestamps, IME composition fixes, model-switch toast, approval queue multi-slot, live model fetching SSRF guard, orphaned tool-message sanitization, profile polish sprint (model routing, workspace cross-profile, legacy session backfill), font-size CSS fix | 1777 |
| v0.50.140v0.50.147 | Bug batch + appearance | Font size setting visibly scales UI text (#843), slash command echoed as user message (#840), scroll selected item into view (#838), tasks refresh button (#835), font size toggle (#833), stale model fix (#829), session search clear on boot (#822), gateway SSE polling fallback (#635) | 1858 |
| v0.50.148v0.50.150 | Session index + read-path + profile | Prune stale _index.json ghost rows after session-id rotation (#847 @franksong2702), GET /api/session side-effect-free model resolution (#848 @franksong2702), profile switching cookie persist + syncTopbar fix (#849 @migueltavares) | 1858 |
| v0.50.151 | credential_pool + Ollama Cloud | Providers added via auth store credential_pool now visible in model dropdown; Ollama Cloud support; ambient gh-cli token suppression; _apply_provider_prefix helper (#820 @starship-s) | 1898 |
| v0.50.152 | Image rendering + auto-title | image_generate MEDIA: token renders all https:// URLs as img regardless of extension (closes #853); auto-title strips Qwen3-style plain-text thinking preambles (closes #857) | 1898 |
| v0.50.153 | Portal model routing | Live-fetched models from portal providers (Nous, OpenCode) now get @provider: prefix so they route correctly instead of falling through to OpenRouter (closes #854) | 1898 |
| v0.50.154 | Thinking card mirror fix | _streamDisplay() early return removed — thinking card and main response now show distinct content when provider double-emits (closes #852) | 1898 |
| v0.50.155 | Honcho session stability | gateway_session_key=session_id passed to AIAgent so Honcho per-session strategy maintains one Honcho session per WebUI chat instead of one per turn (closes #855) | 1903 |
| v0.50.156 | Auto-install security gate | auto_install_agent_deps() is now opt-in; set HERMES_WEBUI_AUTO_INSTALL=1 to enable; _trusted_agent_dir() checks ownership/permission bits before running pip (⚠️ breaking: default changed) | 1903 |
---
@@ -68,14 +93,14 @@
| Layer | Location | Status |
|-------|----------|--------|
| Python server | <repo>/server.py (~81 lines) + api/ modules (~3210 lines) | Thin shell + auth middleware + business logic in api/ |
| HTML template | <repo>/static/index.html (~364 lines) | Served from disk |
| CSS | <repo>/static/style.css (~670 lines) | Served from disk, incl. mobile responsive |
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3610 lines total |
| Python server | <repo>/server.py (~165 lines) + api/ modules (~5000 lines) | Thin shell + QuietHTTPServer + auth middleware + business logic in api/ |
| HTML template | <repo>/static/index.html (~600 lines) | Served from disk |
| CSS | <repo>/static/style.css (~1050 lines) | Served from disk, incl. mobile responsive, KaTeX, table styles |
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands,icons,i18n,login}.js | 10 modules, ~7100 lines total |
| Docker | Dockerfile, docker-compose.yml, .dockerignore | python:3.12-slim, multi-arch (amd64+arm64) |
| CI/CD | .github/workflows/release.yml | Auto-release + GHCR publish on tag push |
| Runtime state | ~/.hermes/webui-mvp/sessions/ | Session JSON files |
| Test server | Port 8788, state dir ~/.hermes/webui-mvp-test/ | Isolated, wiped per run |
| Test server | Port 8788 (conftest.py), port 8789 (browser sanity) | Isolated, wiped per run |
| Production server | Port 8787 | SSH tunnel from Mac |
---
@@ -210,7 +235,7 @@
- [x] Voice input via Web Speech API (Sprint 20)
### Mobile
- [x] Mobile responsive layout — hamburger sidebar, bottom nav, files slide-over (Sprint 21)
- [x] Mobile responsive layout — hamburger sidebar, sidebar tabs on phones, files slide-over (Sprint 21 + later mobile nav simplification)
### Profiles
- [x] Multi-profile support — create, switch, delete profiles (Sprint 22, Issue #28)

View File

@@ -1,22 +1,27 @@
# Hermes Web UI -- Forward Sprint Plan
> Current state: v0.36 | 433 tests | Daily driver ready
> This document plans the path from here to two targets:
> Current state: v0.50.156 | 1903 tests | Full daily driver — CLI parity achieved
>
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
> terminal, you can do from the browser)
> NOTE: This file is preserved as a historical planning record. Current sprint state
> and version history live in CHANGELOG.md and ROADMAP.md.
>
> Target B: 1:1 parity with Claude's reproducible features (the full Claude
> browser UI experience, minus things only Anthropic can build)
> Target A (CLI parity): ✅ Complete — all core tools, workspace, cron, skills,
> memory, sessions, profiles, model routing, streaming, voice, mobile.
>
> Sprints are ordered by impact. Each builds on the one before.
> Past sprint history lives in CHANGELOG.md.
> Target B (Claude parity): ~90% — thinking display, math rendering (KaTeX),
> tool cards, workspace preview, onboarding, settings panel all done.
> Remaining: full subagent transparency UI, file diff viewer.
>
> Last meaningful update: v0.50.21 (April 13, 2026). See CHANGELOG.md for full history.
---
## Where we are now (v0.36)
## Where we are now (v0.50.21 — updated April 2026)
**CLI parity: ~95% complete.** Core agent loop, all tools visible, workspace
> The sections below describe the state as of v0.36 for historical reference.
> See ROADMAP.md for the current sprint history table (v0.36 → v0.50.21).
**CLI parity: ✅ Complete** as of v0.50.x. Core agent loop, all tools visible, workspace
file ops with tree view and git detection, cron/skills/memory CRUD, session
management, streaming with rAF throttle, cancel, multi-provider models, custom
endpoint discovery, slash commands (help/clear/model/workspace/new/usage/theme/compact),

View File

@@ -1,15 +1,17 @@
# Hermes Web UI: Browser Testing Plan
> This document is for manual browser testing by you or by a Claude browser agent.
> It covers user-facing features of the UI through Sprint 26 (v0.36.2) and later releases.
> It covers user-facing features of the UI through v0.50.21 and later releases.
> Each section is written as a step-by-step test procedure with expected outcomes.
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
>
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
>
> Automated tests: 700 total (700 passing, 0 skipped, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), and the `/api/onboarding/*` backend.
> Automated coverage: 2239 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Run: `pytest tests/ -v --timeout=60`
>
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
---
@@ -1686,6 +1688,13 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
- Click a directory toggle arrow (▸) → expands in-place showing children.
- Click again (▾) → collapses. Double-click navigates into it (breadcrumb view).
- If model returns thinking blocks (Claude extended thinking), verify collapsible gold card appears above response.
- Verify the thinking card has a tinted background, visible border, and rounded corners like a tool card, but in the gold thinking palette.
- Open and close a thinking card. Verify the caret rotation and the content reveal both animate smoothly instead of snapping open.
### UI Polish: Tool Card Disclosure Animation
- Trigger a response with at least one completed tool call card.
- Open and close the tool call card. Verify the caret rotates smoothly and the args/result section animates open and closed instead of appearing instantly.
- If a turn has 2+ tool cards, use "Expand all / Collapse all" and verify the same smooth animation applies to every card in the group.
### Sprint 19: Auth + Security
- No password set: everything works as normal. No login page.
@@ -1715,12 +1724,13 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
- Open on mobile viewport (<640px): hamburger icon visible in topbar.
- Tap hamburger → sidebar slides in from left with backdrop overlay.
- Tap outside sidebar → closes. Tap a session → closes and loads session.
- Bottom navigation bar: 5 tabs (Chat, Tasks, Skills, Memory, Spaces).
- Tap "Tasks" in bottom nav → sidebar opens showing Tasks panel.
- Tap "Chat" in bottom nav → sidebar closes (chat is in main area).
- Sidebar top nav remains visible inside the mobile drawer; includes Chat/Tasks/Skills/Memory/Spaces/Profile tabs.
- Tap "Tasks" in the drawer nav → Tasks panel opens in the sidebar drawer.
- Tap "Chat" in the drawer nav → sidebar closes and chat is unobstructed in the main area.
- Files button in topbar → right panel slides in from right.
- No fixed mobile bottom nav; chat transcript and composer use the reclaimed vertical space.
- All touch targets are at least 44px (session items, buttons, icons).
- Desktop viewport (>640px): no hamburger, no bottom nav, no mobile elements.
- Desktop viewport (>640px): no hamburger or mobile overlay; desktop layout unchanged.
- Docker: `docker compose up -d` starts server on port 8787.
- Docker: session data persists across container restarts (named volume).
@@ -1739,8 +1749,41 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
---
*Last updated: v0.47.0, April 11, 2026*
*Total automated tests: 645 (645 passing, 0 failures)*
## Slash command parity (manual checklist)
For each batch-1 command, run via webui slash menu AND via `hermes` CLI in the
same `HERMES_HOME` (when applicable) and verify identical effect.
- [ ] `/help` — dropdown lists 25+ commands; selecting `/help` posts an assistant message listing them.
- [ ] `/new` (and alias `/reset`) — starts fresh session.
- [ ] `/clear` — clears current transcript display (webui-only meaning, distinct from CLI's "clear screen").
- [ ] `/title <name>` — renames active session, topbar + sidebar update; `/title` alone shows current title.
- [ ] `/status` — assistant message shows session_id, model, workspace, message count.
- [ ] `/usage` — assistant message shows token counts; the "show token usage" setting is unchanged (toggle still in Settings panel).
- [ ] `/stop` — interrupts a running stream; with no active stream toasts "No active task to stop."
- [ ] `/retry` — removes last user+assistant exchange, refills composer with last user text, resends. Final transcript has only ONE copy of the resent message.
- [ ] `/undo` — removes last user+assistant exchange; toast confirms; repeated until empty toasts "Nothing to undo."
- [ ] `/model <name>` — switches model dropdown.
- [ ] `/personality` — lists personalities; `/personality <name>` switches.
- [ ] `/skills [query]` — lists matching skills.
- [ ] `/theme <name>` — switches webui theme.
- [ ] `/workspace <name>` — switches workspace.
Unknown / deferred:
- [ ] `/yolo`, `/reasoning`, `/voice`, `/branch`, `/insights`, `/debug`, `/reload`, etc. — toast "Web UI 暂未实现该命令: /<name>". MUST NOT be sent as plain text to the LLM.
- [ ] `/compact` — toast "/compress is not available in the web UI yet — use the CLI for now." (was sending free text to LLM before this batch.)
- [ ] Made-up command (e.g. `/fhfajl`) — fall through to send as text (existing behavior preserved for typos vs. real commands).
Bridged CLI sessions:
- [ ] Open a CLI-bridged session in webui sidebar (if `show_cli_sessions` setting enabled).
- [ ] `/retry`, `/undo` toast "该命令仅支持 Web UI 原生会话…" and do nothing.
---
*Last updated: v0.50.91, April 19, 2026*
*Total automated tests collected: 2107*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*

55
api/agent_sessions.py Normal file
View File

@@ -0,0 +1,55 @@
"""Shared helpers for reading Hermes Agent sessions from state.db."""
import logging
import sqlite3
from pathlib import Path
logger = logging.getLogger(__name__)
def read_importable_agent_session_rows(db_path: Path, limit: int = 200, log=None) -> list[dict]:
"""Return non-WebUI agent sessions that have readable message rows.
Hermes Agent can create rows in ``state.db.sessions`` before a session has
any messages. WebUI cannot import those rows, so both the regular
``/api/sessions`` path and the gateway SSE watcher must filter them the
same way.
"""
db_path = Path(db_path)
if not db_path.exists():
return []
log = log or logger
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# Older Hermes Agent versions may not have source tracking. Without a
# source column we cannot safely distinguish WebUI rows from agent rows.
cur.execute("PRAGMA table_info(sessions)")
session_cols = {row[1] for row in cur.fetchall()}
if 'source' not in session_cols:
log.warning(
"agent session listing skipped: state.db at %s has no 'source' column "
"(older hermes-agent?). Agent sessions unavailable. "
"Upgrade hermes-agent to fix this.",
db_path,
)
return []
cur.execute(
"""
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
COUNT(m.id) AS actual_message_count,
MAX(m.timestamp) AS last_activity
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE s.source IS NOT NULL AND s.source != 'webui'
GROUP BY s.id
HAVING COUNT(m.id) > 0
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
LIMIT ?
""",
(int(limit),),
)
return [dict(row) for row in cur.fetchall()]

View File

@@ -6,12 +6,17 @@ or configuring a password in the Settings panel.
import hashlib
import hmac
import http.cookies
import json
import logging
import os
import secrets
import tempfile
import time
from api.config import STATE_DIR, load_settings
logger = logging.getLogger(__name__)
# ── Public paths (no auth required) ─────────────────────────────────────────
PUBLIC_PATHS = frozenset({
'/login', '/health', '/favicon.ico',
@@ -21,8 +26,54 @@ PUBLIC_PATHS = frozenset({
COOKIE_NAME = 'hermes_session'
SESSION_TTL = 86400 # 24 hours
# Active sessions: token -> expiry timestamp
_sessions = {}
_SESSIONS_FILE = STATE_DIR / '.sessions.json'
def _load_sessions() -> dict[str, float]:
"""Load persisted sessions from STATE_DIR, pruning expired entries.
Returns an empty dict on any read or parse error so startup is never
blocked by a corrupt or missing sessions file.
"""
try:
if _SESSIONS_FILE.exists():
data = json.loads(_SESSIONS_FILE.read_text(encoding='utf-8'))
if not isinstance(data, dict):
raise ValueError('malformed sessions file — expected dict')
now = time.time()
return {t: exp for t, exp in data.items()
if isinstance(t, str) and isinstance(exp, (int, float)) and exp > now}
except Exception as e:
logger.debug("Failed to load sessions file, starting fresh: %s", e)
return {}
def _save_sessions(sessions: dict[str, float]) -> None:
"""Atomically persist sessions to STATE_DIR/.sessions.json (0600).
Uses a temp file + os.replace() so a crash mid-write never leaves a
truncated file. Mirrors the same pattern as .signing_key persistence.
"""
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=STATE_DIR, suffix='.sessions.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(sessions, f)
os.chmod(tmp, 0o600)
os.replace(tmp, _SESSIONS_FILE)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception as e:
logger.debug("Failed to persist sessions: %s", e)
# Active sessions: token -> expiry timestamp (persisted across restarts via STATE_DIR)
_sessions = _load_sessions()
# ── Login rate limiter ──────────────────────────────────────────────────────
_login_attempts = {} # ip -> [timestamp, ...]
@@ -48,13 +99,13 @@ def _record_login_attempt(ip: str) -> None:
def _signing_key():
"""Return a random signing key, generating and persisting one on first call."""
key_file = STATE_DIR / '.signing_key'
if key_file.exists():
try:
try:
if key_file.exists():
raw = key_file.read_bytes()
if len(raw) >= 32:
return raw[:32]
except Exception:
pass
except Exception:
logger.debug("Failed to read or access signing key file, using in-memory key")
# Generate a new random key
key = secrets.token_bytes(32)
try:
@@ -62,7 +113,7 @@ def _signing_key():
key_file.write_bytes(key)
key_file.chmod(0o600)
except Exception:
pass # key works for this process even if persist fails
logger.debug("Failed to persist signing key, using in-memory key only")
return key
@@ -104,6 +155,7 @@ def create_session() -> str:
"""Create a new auth session. Returns signed cookie value."""
token = secrets.token_hex(32)
_sessions[token] = time.time() + SESSION_TTL
_save_sessions(_sessions)
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
return f"{token}.{sig}"
@@ -111,8 +163,11 @@ def create_session() -> str:
def _prune_expired_sessions():
"""Remove all expired session entries to prevent unbounded memory growth."""
now = time.time()
for token in [t for t, exp in _sessions.items() if now > exp]:
_sessions.pop(token, None)
expired = [t for t, exp in _sessions.items() if now > exp]
if expired:
for token in expired:
_sessions.pop(token, None)
_save_sessions(_sessions)
def verify_session(cookie_value) -> bool:
@@ -135,7 +190,9 @@ def invalidate_session(cookie_value) -> None:
"""Remove a session token."""
if cookie_value and '.' in cookie_value:
token = cookie_value.rsplit('.', 1)[0]
_sessions.pop(token, None)
if token in _sessions:
_sessions.pop(token, None)
_save_sessions(_sessions)
def parse_cookie(handler) -> str | None:

87
api/background.py Normal file
View File

@@ -0,0 +1,87 @@
"""Background and ephemeral task tracking for /background and /btw commands."""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
logger = logging.getLogger(__name__)
_lock = threading.Lock()
# parent_session_id -> list of task dicts
_BACKGROUND_TASKS: dict[str, list[dict[str, Any]]] = {}
# btw ephemeral session tracking: parent_sid -> {ephemeral_sid, stream_id, question}
_BTW_TRACKING: dict[str, dict[str, Any]] = {}
def track_background(parent_sid: str, bg_sid: str, stream_id: str,
task_id: str, prompt: str) -> None:
with _lock:
_BACKGROUND_TASKS.setdefault(parent_sid, []).append({
"task_id": task_id,
"bg_session_id": bg_sid,
"stream_id": stream_id,
"prompt": prompt,
"status": "running",
"started_at": time.time(),
"answer": None,
"completed_at": None,
})
def track_btw(parent_sid: str, ephemeral_sid: str, stream_id: str,
question: str) -> None:
with _lock:
_BTW_TRACKING[parent_sid] = {
"ephemeral_session_id": ephemeral_sid,
"stream_id": stream_id,
"question": question,
}
def complete_background(parent_sid: str, task_id: str, answer: str) -> None:
with _lock:
for t in _BACKGROUND_TASKS.get(parent_sid, []):
if t["task_id"] == task_id and t["status"] == "running":
t["status"] = "done"
t["answer"] = answer
t["completed_at"] = time.time()
break
def get_results(parent_sid: str) -> list[dict[str, Any]]:
"""Return completed background task results and remove only the done ones
from tracking. Tasks still in ``status="running"`` MUST stay in the list
so that ``complete_background()`` can still find them when the worker
thread finishes — otherwise the first poll during a long-running task
silently drops it and the result is lost forever.
"""
with _lock:
tasks = _BACKGROUND_TASKS.get(parent_sid, [])
done = [t for t in tasks if t["status"] == "done"]
still_running = [t for t in tasks if t["status"] != "done"]
if still_running:
_BACKGROUND_TASKS[parent_sid] = still_running
else:
_BACKGROUND_TASKS.pop(parent_sid, None)
return [{
"task_id": t["task_id"],
"prompt": t["prompt"],
"answer": t["answer"],
"completed_at": t["completed_at"],
} for t in done]
def get_background_tasks(parent_sid: str) -> list[dict[str, Any]]:
"""Return all background tasks (running and done) for a parent session."""
with _lock:
return list(_BACKGROUND_TASKS.get(parent_sid, []))
def cleanup_btw(parent_sid: str) -> dict[str, Any] | None:
"""Remove and return btw tracking for a parent session."""
with _lock:
return _BTW_TRACKING.pop(parent_sid, None)

128
api/clarify.py Normal file
View File

@@ -0,0 +1,128 @@
"""Clarify prompt state for the WebUI.
This mirrors the approval flow structure, but the response is a free-form
clarification string instead of an approval decision.
"""
from __future__ import annotations
import threading
from typing import Optional
_lock = threading.Lock()
_pending: dict[str, dict] = {}
_gateway_queues: dict[str, list] = {}
_gateway_notify_cbs: dict[str, object] = {}
class _ClarifyEntry:
"""One pending clarify request inside a session."""
__slots__ = ("event", "data", "result")
def __init__(self, data: dict):
self.event = threading.Event()
self.data = data
self.result: Optional[str] = None
def register_gateway_notify(session_key: str, cb) -> None:
"""Register a per-session callback for sending clarify requests to the UI."""
with _lock:
_gateway_notify_cbs[session_key] = cb
def _clear_queue_locked(session_key: str) -> list[_ClarifyEntry]:
entries = _gateway_queues.pop(session_key, [])
_pending.pop(session_key, None)
return entries
def unregister_gateway_notify(session_key: str) -> None:
"""Unregister the per-session callback and unblock any waiting clarify prompt."""
with _lock:
_gateway_notify_cbs.pop(session_key, None)
entries = _clear_queue_locked(session_key)
for entry in entries:
entry.event.set()
def clear_pending(session_key: str) -> int:
"""Clear any pending clarify prompts for the session without removing the callback."""
with _lock:
entries = _clear_queue_locked(session_key)
for entry in entries:
entry.event.set()
return len(entries)
def submit_pending(session_key: str, data: dict) -> _ClarifyEntry:
"""Queue a pending clarify request and notify the UI callback if registered."""
with _lock:
queue = _gateway_queues.setdefault(session_key, [])
# De-duplicate while unresolved: if the most recent pending clarify is
# semantically identical, reuse it instead of stacking duplicates.
if queue:
last = queue[-1]
if (
str(last.data.get("question", "")) == str(data.get("question", ""))
and list(last.data.get("choices_offered") or [])
== list(data.get("choices_offered") or [])
):
entry = last
cb = _gateway_notify_cbs.get(session_key)
# Keep _pending aligned to the oldest unresolved entry.
_pending[session_key] = queue[0].data
if cb:
try:
cb(dict(entry.data))
except Exception:
pass
return entry
entry = _ClarifyEntry(data)
queue.append(entry)
_pending[session_key] = queue[0].data
cb = _gateway_notify_cbs.get(session_key)
if cb:
try:
cb(data)
except Exception:
pass
return entry
def get_pending(session_key: str) -> dict | None:
"""Return the oldest pending clarify request for this session, if any."""
with _lock:
queue = _gateway_queues.get(session_key) or []
if queue:
return dict(queue[0].data)
pending = _pending.get(session_key)
return dict(pending) if pending else None
def has_pending(session_key: str) -> bool:
with _lock:
return bool(_gateway_queues.get(session_key))
def resolve_clarify(session_key: str, response: str, resolve_all: bool = False) -> int:
"""Resolve the oldest pending clarify request for a session."""
with _lock:
queue = _gateway_queues.get(session_key)
if not queue:
_pending.pop(session_key, None)
return 0
entries = list(queue) if resolve_all else [queue.pop(0)]
if queue:
_pending[session_key] = queue[0].data
else:
_clear_queue_locked(session_key)
count = 0
for entry in entries:
entry.result = response
entry.event.set()
count += 1
return count

56
api/commands.py Normal file
View File

@@ -0,0 +1,56 @@
"""Expose hermes-agent's COMMAND_REGISTRY to the webui frontend.
This module is the single integration point with hermes_cli.commands.
If hermes-agent is unavailable the endpoint degrades to an empty list
so the frontend can still load with WEBUI_ONLY commands.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
# Commands that are gateway_only in the agent registry -- webui never
# wants to expose them (sethome, restart, update etc.) even if a future
# agent version drops the gateway_only flag. /commands is the agent's
# own command-listing command; webui has its own /help that calls
# cmdHelp() locally, so /commands would be redundant and confusing.
_NEVER_EXPOSE: frozenset[str] = frozenset({
'sethome', 'restart', 'update', 'commands',
})
def list_commands(_registry=None) -> list[dict[str, Any]]:
"""Return COMMAND_REGISTRY entries as JSON-friendly dicts.
Returns empty list if hermes_cli is not installed (graceful
degradation -- the frontend has its own fallback minimum set).
Args:
_registry: Optional injected registry for testing. When None
(production), imports COMMAND_REGISTRY from hermes_cli.
"""
if _registry is None:
try:
from hermes_cli.commands import COMMAND_REGISTRY as _registry
except ImportError:
logger.warning("hermes_cli.commands not importable -- /api/commands returns []")
return []
out: list[dict[str, Any]] = []
for cmd in _registry:
if cmd.gateway_only:
continue
if cmd.name in _NEVER_EXPOSE:
continue
out.append({
'name': cmd.name,
'description': cmd.description,
'category': cmd.category,
'aliases': list(cmd.aliases),
'args_hint': cmd.args_hint,
'subcommands': list(cmd.subcommands),
'cli_only': bool(cmd.cli_only),
'gateway_only': bool(cmd.gateway_only),
})
return out

File diff suppressed because it is too large Load Diff

View File

@@ -10,14 +10,17 @@ requiring any changes to hermes-agent.
"""
import hashlib
import json
import logging
import os
import queue
import sqlite3
import threading
import time
from pathlib import Path
from api.config import HOME
from api.agent_sessions import read_importable_agent_session_rows
logger = logging.getLogger(__name__)
# ── State hash tracking ─────────────────────────────────────────────────────
@@ -28,7 +31,7 @@ def _snapshot_hash(sessions: list) -> str:
f"{s['session_id']}:{s.get('updated_at', 0)}:{s.get('message_count', 0)}"
for s in sorted(sessions, key=lambda x: x['session_id'])
)
return hashlib.md5(key.encode()).hexdigest()
return hashlib.md5(key.encode(), usedforsecurity=False).hexdigest()
# ── DB resolution (shared pattern with state_sync.py) ──────────────────────
@@ -52,32 +55,18 @@ def _get_agent_sessions_from_db() -> list:
return []
try:
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("""
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
MAX(m.timestamp) AS last_activity
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE s.source IS NOT NULL AND s.source != 'webui'
GROUP BY s.id
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
LIMIT 200
""")
sessions = []
for row in cur.fetchall():
sessions.append({
'session_id': row['id'],
'title': row['title'] or 'Agent Session',
'model': row['model'] or 'unknown',
'message_count': row['message_count'] or 0,
'created_at': row['started_at'],
'updated_at': row['last_activity'] or row['started_at'],
'source': row['source'] or 'cli',
})
return sessions
sessions = []
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger):
sessions.append({
'session_id': row['id'],
'title': row['title'] or 'Agent Session',
'model': row['model'] or None,
'message_count': row['message_count'] or row['actual_message_count'] or 0,
'created_at': row['started_at'],
'updated_at': row['last_activity'] or row['started_at'],
'source': row['source'] or 'cli',
})
return sessions
except Exception:
return []
@@ -115,6 +104,19 @@ class GatewayWatcher:
self._thread = threading.Thread(target=self._poll_loop, daemon=True, name='gateway-watcher')
self._thread.start()
def is_alive(self) -> bool:
"""Return True when the poll thread is running.
Public accessor used by ``/api/sessions/gateway/stream`` probe mode and
the live SSE handler to detect a watcher instance whose poll thread
died silently (e.g. uncaught exception in ``_poll_loop``). Callers
use this to decide whether to return 503 and trigger the client-side
polling fallback, instead of handing out an SSE connection that would
never emit events.
"""
t = self._thread
return t is not None and t.is_alive()
def stop(self):
"""Stop the watcher thread."""
self._stop_event.set()
@@ -124,7 +126,7 @@ class GatewayWatcher:
try:
q.put(None) # sentinel
except Exception:
pass
logger.debug("Failed to send sentinel to subscriber")
if self._thread:
self._thread.join(timeout=3)
self._thread = None
@@ -172,7 +174,7 @@ class GatewayWatcher:
try:
q.put_nowait(None)
except Exception:
pass
logger.debug("Failed to send sentinel to dead subscriber")
def _poll_loop(self):
"""Main polling loop. Runs in a daemon thread."""
@@ -186,7 +188,7 @@ class GatewayWatcher:
self._last_sessions = sessions
self._notify_subscribers(sessions)
except Exception:
pass # never crash the watcher
logger.debug("Error in gateway watcher poll loop", exc_info=True)
# Sleep in small increments so we can stop promptly
for _ in range(self.POLL_INTERVAL * 10):

View File

@@ -42,26 +42,52 @@ def _security_headers(handler):
handler.send_header('Referrer-Policy', 'same-origin')
handler.send_header(
'Content-Security-Policy',
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"default-src 'self' https://*.cloudflareaccess.com; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"img-src 'self' data:; font-src 'self' data:; connect-src 'self'; "
"img-src 'self' data: https: blob:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; "
"manifest-src 'self' https://*.cloudflareaccess.com; "
"base-uri 'self'; form-action 'self'"
)
handler.send_header(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
'camera=(), microphone=(self), geolocation=()'
)
def j(handler, payload, status: int=200) -> None:
"""Send a JSON response."""
def _accepts_gzip(handler) -> bool:
"""Check if the client accepts gzip encoding."""
headers = getattr(handler, 'headers', None)
if not headers:
return False
ae = headers.get('Accept-Encoding', '')
return 'gzip' in ae
def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
"""Send a JSON response.
*extra_headers*: optional dict of additional headers to include
(e.g., {'Set-Cookie': '...'}). Headers are sent before end_headers().
"""
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
handler.send_response(status)
handler.send_header('Content-Type', 'application/json; charset=utf-8')
# Gzip-compress responses over 1KB when the client accepts it.
# Typical JSON API responses compress 70-80%, giving a big speedup
# for large payloads (session history, message lists).
if _accepts_gzip(handler) and len(body) > 1024:
import gzip
body = gzip.compress(body, compresslevel=4)
handler.send_header('Content-Encoding', 'gzip')
handler.send_header('Content-Length', str(len(body)))
handler.send_header('Cache-Control', 'no-store')
_security_headers(handler)
if extra_headers:
for k, v in extra_headers.items():
handler.send_header(k, v)
handler.end_headers()
handler.wfile.write(body)
@@ -173,3 +199,50 @@ def read_body(handler) -> dict:
return _json.loads(raw)
except Exception:
return {}
# ── Profile cookie helpers (issue #798) ─────────────────────────────────────
PROFILE_COOKIE_NAME = 'hermes_profile'
def get_profile_cookie(handler) -> str | None:
"""Extract the hermes_profile cookie value from the request, or None."""
cookie_header = handler.headers.get('Cookie', '')
if not cookie_header:
return None
import http.cookies as _hc
cookie = _hc.SimpleCookie()
try:
cookie.load(cookie_header)
except _hc.CookieError:
return None
morsel = cookie.get(PROFILE_COOKIE_NAME)
if morsel and morsel.value:
# Validate against profile-name pattern before trusting
from api.profiles import _PROFILE_ID_RE
val = morsel.value
if val == 'default' or _PROFILE_ID_RE.fullmatch(val):
return val
return None
def build_profile_cookie(name: str) -> str:
"""Build a Set-Cookie header value for the hermes_profile cookie.
Always persist the selected profile in the cookie, including 'default'.
Clearing the cookie causes the backend to fall back to process-global
_active_profile, which can unexpectedly switch clients back to another
profile.
Set HttpOnly because the UI reads the active profile from
/api/profile/active JSON and does not need to access this cookie via
document.cookie.
"""
import http.cookies as _hc
cookie = _hc.SimpleCookie()
cookie[PROFILE_COOKIE_NAME] = name
cookie[PROFILE_COOKIE_NAME]['path'] = '/'
cookie[PROFILE_COOKIE_NAME]['httponly'] = True
cookie[PROFILE_COOKIE_NAME]['samesite'] = 'Lax'
return cookie[PROFILE_COOKIE_NAME].OutputString()

187
api/metering.py Normal file
View File

@@ -0,0 +1,187 @@
"""
Hermes Web UI -- Streaming performance metering.
Tracks Tokens Per Second (TPS) across all active WebUI sessions, and the
HIGH/LOW TPS values observed over the past 60 minutes. Metering data is
emitted via SSE events so the header label can update live during a stream.
Architecture
────────────
Each streaming session is tracked independently. TPS per session is:
session_tps = total_tokens / (last_token_ts - first_token_ts)
The global tps is the average of all currently active sessions' TPS values.
This correctly represents the system's real-time capacity regardless of how
many sessions are running or how long each has been streaming.
For HIGH/LOW tracking, every stats snapshot records the current global tps
(only when > 0 — idle periods are skipped) into a rolling 60-minute history.
The max/min of that history gives the peak throughput observed over the past hour.
The ticker in streaming.py calls get_interval() — it returns 1.0 when sessions
are actively receiving tokens so the header updates at 1 Hz, and 10.0 when idle
so the ticker exits and no idle readings are emitted.
Usage from api/streaming.py
─────────────────────────────
from api.metering import meter
meter().begin_session(stream_id) # stream starts
meter().record_token(stream_id, running_output) # per output token
meter().record_reasoning(stream_id, running_reasoning_len) # per reasoning token
The SSE `metering` event payload:
{
"tps": 47.3, # average TPS across active sessions (real-time)
"high": 52.1, # highest average TPS observed in the past 60 minutes
"low": 31.4, # lowest average TPS (excl. readings < 1 tps, to ignore idle)
"active": 1, # sessions currently streaming
}
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
_HOUR_SECS = 3600.0 # rolling window for HIGH/LOW tracking
_STALE_SECS = 60.0 # consider a session inactive after this
@dataclass
class _SessionMeter:
output_tokens: int = 0
reasoning_tokens: int = 0
first_token_ts: float = 0.0 # time.monotonic() of first token received
last_token_ts: float = 0.0 # time.monotonic() of last token received
def total_tokens(self) -> int:
return self.output_tokens + self.reasoning_tokens
def tps(self) -> float:
if self.first_token_ts == 0.0 or self.last_token_ts <= self.first_token_ts:
return 0.0
return self.total_tokens() / (self.last_token_ts - self.first_token_ts)
class GlobalMeter:
"""Thread-safe global streaming meter.
Tracks per-session TPS, averages them for a global tps, and maintains a
60-minute rolling history of global tps snapshots for HIGH/LOW reporting.
"""
__slots__ = (
'_lock',
'_sessions', # stream_id -> _SessionMeter
'_readings', # [(monotonic_ts, tps), ...] rolling 60-minute history
'_window_start', # monotonic ts of current window
)
def __init__(self) -> None:
self._lock = threading.Lock()
self._sessions: dict[str, _SessionMeter] = {}
self._readings: list[tuple[float, float]] = []
self._window_start: float = time.monotonic()
# ── Public API ────────────────────────────────────────────────────────────
def begin_session(self, stream_id: str) -> None:
with self._lock:
self._sessions[stream_id] = _SessionMeter()
def get_interval(self) -> float:
"""Return 1.0 when sessions are actively receiving tokens, 10.0 when idle.
Used by the streaming ticker to run at 1 Hz during work and exit when
there is nothing to measure.
"""
now = time.monotonic()
with self._lock:
# Only count sessions that have received at least one token recently.
active_sids = {
sid for sid, s in self._sessions.items()
if s.first_token_ts > 0 and (now - s.last_token_ts) <= _STALE_SECS
}
return 1.0 if active_sids else 10.0
def record_token(self, stream_id: str, running_output_tokens: int) -> None:
now = time.monotonic()
with self._lock:
s = self._sessions.get(stream_id)
if s is None:
return
if s.first_token_ts == 0.0:
s.first_token_ts = now
s.last_token_ts = now
s.output_tokens = running_output_tokens
def record_reasoning(self, stream_id: str, running_reasoning_tokens: int) -> None:
now = time.monotonic()
with self._lock:
s = self._sessions.get(stream_id)
if s is None:
return
if s.first_token_ts == 0.0:
s.first_token_ts = now
s.last_token_ts = now
s.reasoning_tokens = running_reasoning_tokens
def end_session(self, stream_id: str, final_output_tokens: int, input_tokens: int = 0) -> None:
with self._lock:
self._sessions.pop(stream_id, None)
def get_stats(self) -> dict:
now = time.monotonic()
with self._lock:
# Prune stale sessions
stale = [
sid for sid, s in self._sessions.items()
if s.first_token_ts > 0 and (now - s.last_token_ts) > _STALE_SECS
]
for sid in stale:
self._sessions.pop(sid, None)
# Reset window if everything went stale
if not self._sessions:
self._window_start = now
# Compute global tps: average of per-session TPS values
active = [s for s in self._sessions.values() if s.first_token_ts > 0]
if active:
global_tps = sum(s.tps() for s in active) / len(active)
else:
global_tps = 0.0
# Prune readings older than 1 hour
cutoff = now - _HOUR_SECS
self._readings = [(ts, v) for ts, v in self._readings if ts > cutoff]
# Only record this snapshot for HIGH/LOW if there is active work.
# This prevents idle periods from flooding the history and keeps
# HIGH/LOW meaningful for the past hour of actual throughput.
if global_tps > 0:
self._readings.append((now, global_tps))
# HIGH/LOW from the past hour (skip near-zero idle readings)
active_readings = [v for _, v in self._readings if v >= 1.0]
high = max(active_readings) if active_readings else 0.0
low = min(active_readings) if active_readings else 0.0
return {
'tps': round(global_tps, 1),
'high': round(high, 1),
'low': round(low, 1),
'active': len(self._sessions),
}
# ── Module-level singleton ─────────────────────────────────────────────────────
_meter = GlobalMeter()
def meter() -> GlobalMeter:
return _meter

View File

@@ -1,8 +1,9 @@
"""
Hermes Web UI -- Session model and in-memory session store.
"""
"""Hermes Web UI -- Session model and in-memory session store."""
import collections
import json
import logging
import os
import threading
import time
import uuid
from pathlib import Path
@@ -10,27 +11,296 @@ from pathlib import Path
import api.config as _cfg
from api.config import (
SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX,
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME
LOCK, STREAMS, STREAMS_LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME,
get_effective_default_model,
)
from api.workspace import get_last_workspace
from api.agent_sessions import read_importable_agent_session_rows
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Stale temp-file cleanup
# ---------------------------------------------------------------------------
# Both Session.save() and _write_session_index() use the atomic-write pattern:
# write to <path>.tmp.<pid>.<tid> → os.replace() to final path
# If the process crashes between write and replace the .tmp file is left
# behind. Because the name embeds pid + tid, leftover files can never be
# reused by a different process/thread, so they are safe to remove on the
# next startup. _cleanup_stale_tmp_files() is called from the full-rebuild
# path of _write_session_index (i.e. at first index access / startup) and
# removes any *.tmp.* file whose mtime is older than one hour.
# ---------------------------------------------------------------------------
_STALE_TMP_AGE_SECONDS = 3600 # 1 hour
# Serializes index writers so concurrent Session.save() calls cannot race on
# stale baselines while still allowing LOCK to be released before disk I/O.
_INDEX_WRITE_LOCK = threading.RLock()
def _write_session_index():
"""Rebuild the session index file for O(1) future reads."""
entries = []
for p in SESSION_DIR.glob('*.json'):
if p.name.startswith('_'): continue
def _cleanup_stale_tmp_files() -> None:
"""Best-effort removal of stale ``*.tmp.*`` files from SESSION_DIR.
Only files whose mtime is older than ``_STALE_TMP_AGE_SECONDS`` are
removed so that in-flight writes from a long-running sibling process
are not disturbed. Errors are logged and swallowed — this must never
prevent startup.
"""
cutoff = time.time() - _STALE_TMP_AGE_SECONDS
try:
for p in SESSION_DIR.glob('*.tmp.*'):
try:
if p.stat().st_mtime < cutoff:
p.unlink(missing_ok=True)
logger.debug("Cleaned up stale tmp file: %s", p.name)
except OSError:
pass # best-effort
except Exception:
pass # SESSION_DIR may not exist yet; that's fine
def _index_entry_exists(session_id: str, in_memory_ids=None) -> bool:
"""Return True if an index entry still has backing state.
A session can legitimately exist either as a persisted JSON file or as an
in-memory Session object that has not been flushed yet. This helper is used
to prune stale `_index.json` rows left behind after session-id rotation or
file removal.
"""
if not session_id:
return False
if in_memory_ids is None:
with LOCK:
in_memory_ids = set(SESSIONS.keys())
if session_id in in_memory_ids:
return True
p = SESSION_DIR / f'{session_id}.json'
return p.exists()
def _write_session_index(updates=None):
"""Update the session index file.
When *updates* is provided (a list of Session objects whose compact
entries should be refreshed), this does a targeted in-place update of
the existing index — O(1) for single-session changes. When *updates*
is None, a full rebuild is performed (used on startup / first call).
LOCK protects in-memory state snapshots and payload construction only;
disk I/O (write/flush/fsync/replace) always runs outside LOCK.
"""
_tmp = SESSION_INDEX_FILE.with_suffix(f'.tmp.{os.getpid()}.{threading.current_thread().ident}')
with _INDEX_WRITE_LOCK:
# Lazy full-rebuild path — used when index doesn't exist yet.
if updates is None or not SESSION_INDEX_FILE.exists():
_cleanup_stale_tmp_files() # best-effort sweep on startup / first call
entries = []
for p in SESSION_DIR.glob('*.json'):
if p.name.startswith('_'):
continue
try:
s = Session.load(p.stem)
if s:
entries.append(s.compact())
except Exception:
logger.debug("Failed to load session from %s", p)
with LOCK:
existing_ids = {e.get('session_id') for e in entries}
for s in SESSIONS.values():
if s.session_id not in existing_ids:
entries.append(s.compact())
entries.sort(key=lambda s: s.get('updated_at', 0), reverse=True)
_payload = json.dumps(entries, ensure_ascii=False, indent=2)
try:
with open(_tmp, 'w', encoding='utf-8') as f:
f.write(_payload)
f.flush()
os.fsync(f.fileno())
os.replace(_tmp, SESSION_INDEX_FILE)
except Exception:
# Best-effort cleanup of stale tmp on failure
try:
_tmp.unlink(missing_ok=True)
except Exception:
pass
raise
return
# Fast path: patch existing index with updated sessions.
# This avoids loading every session file on every single save().
_fallback = False
try:
s = Session.load(p.stem)
if s: entries.append(s.compact())
with LOCK:
existing = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
in_memory_ids = set(SESSIONS.keys())
# Avoid N filesystem exists() checks under LOCK by collecting
# on-disk IDs once.
on_disk_ids = {
p.stem
for p in SESSION_DIR.glob('*.json')
if not p.name.startswith('_')
}
existing = [
e for e in existing
if (e.get('session_id') in in_memory_ids or e.get('session_id') in on_disk_ids)
]
# Build lookup of updated entries
updated_map = {s.session_id: s.compact() for s in updates}
existing_ids = {e.get('session_id') for e in existing}
# Add any updated entries not yet in the index
for sid, entry in updated_map.items():
if sid not in existing_ids:
existing.append(entry)
# Replace matching entries in-place
for i, e in enumerate(existing):
sid = e.get('session_id')
if sid in updated_map:
existing[i] = updated_map[sid]
existing.sort(key=lambda s: s.get('updated_at', 0), reverse=True)
_payload = json.dumps(existing, ensure_ascii=False, indent=2)
try:
with open(_tmp, 'w', encoding='utf-8') as f:
f.write(_payload)
f.flush()
os.fsync(f.fileno())
os.replace(_tmp, SESSION_INDEX_FILE)
except Exception:
try:
_tmp.unlink(missing_ok=True)
except Exception:
pass
raise
except Exception:
pass
with LOCK:
for s in SESSIONS.values():
if not any(e['session_id'] == s.session_id for e in entries):
entries.append(s.compact())
entries.sort(key=lambda s: s['updated_at'], reverse=True)
SESSION_INDEX_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding='utf-8')
_fallback = True
if _fallback:
# Corrupt or missing index — fall back to full rebuild (called outside LOCK to avoid deadlock)
_write_session_index(updates=None)
def _active_stream_ids():
with STREAMS_LOCK:
return set(STREAMS.keys())
def _is_streaming_session(active_stream_id, active_stream_ids):
return bool(active_stream_id and active_stream_id in active_stream_ids)
def _session_sort_timestamp(session):
if isinstance(session, dict):
return session.get('last_message_at') or session.get('updated_at') or 0
return _last_message_timestamp(getattr(session, 'messages', None)) or getattr(session, 'updated_at', 0) or 0
def _message_timestamp(message):
if not isinstance(message, dict):
return None
raw = message.get('_ts') or message.get('timestamp')
try:
return float(raw) if raw is not None else None
except (TypeError, ValueError):
return None
def _last_message_timestamp(messages):
if not isinstance(messages, list):
return None
for message in reversed(messages):
if isinstance(message, dict) and message.get('role') == 'tool':
continue
ts = _message_timestamp(message)
if ts:
return ts
return None
def _find_top_level_json_key(text, key):
"""Return the byte offset of a top-level JSON object key, if present."""
depth = 0
i = 0
n = len(text)
while i < n:
ch = text[i]
if ch == '"':
start = i
i += 1
escaped = False
chars = []
while i < n:
c = text[i]
if escaped:
chars.append(c)
escaped = False
elif c == '\\':
escaped = True
elif c == '"':
break
else:
chars.append(c)
i += 1
if i >= n:
return None
if depth == 1 and ''.join(chars) == key:
j = i + 1
while j < n and text[j] in ' \t\r\n':
j += 1
if j < n and text[j] == ':':
return start
elif ch in '{[':
depth += 1
elif ch in '}]':
depth -= 1
i += 1
return None
def _read_metadata_json_prefix(path, max_prefix_bytes=65536):
"""Read only the metadata portion before the top-level messages array."""
buf = ''
with open(path, 'r', encoding='utf-8') as f:
while len(buf.encode('utf-8')) < max_prefix_bytes:
chunk = f.read(4096)
if not chunk:
return None
buf += chunk
messages_pos = _find_top_level_json_key(buf, 'messages')
if messages_pos is None:
continue
prefix = buf[:messages_pos].rstrip()
if prefix.endswith(','):
prefix = prefix[:-1].rstrip()
return f'{prefix}\n}}'
return None
def _lookup_index_message_count(session_id):
"""Return the indexed message count without loading the full session file."""
try:
entries = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
except Exception:
return None
if not isinstance(entries, list):
return None
for entry in entries:
if entry.get('session_id') != session_id:
continue
count = entry.get('message_count')
if isinstance(count, int) and count >= 0:
return count
try:
count = int(count)
except (TypeError, ValueError):
return None
return count if count >= 0 else None
return None
class Session:
@@ -41,6 +311,12 @@ class Session:
project_id: str=None, profile=None,
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
personality=None,
active_stream_id: str=None,
pending_user_message: str=None,
pending_attachments=None,
pending_started_at=None,
compression_anchor_visible_idx=None,
compression_anchor_message_key=None,
**kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]
self.title = title
@@ -58,18 +334,55 @@ class Session:
self.output_tokens = output_tokens or 0
self.estimated_cost = estimated_cost
self.personality = personality
self.active_stream_id = active_stream_id
self.pending_user_message = pending_user_message
self.pending_attachments = pending_attachments or []
self.pending_started_at = pending_started_at
self.compression_anchor_visible_idx = compression_anchor_visible_idx
self.compression_anchor_message_key = compression_anchor_message_key
self._metadata_message_count = None
@property
def path(self):
return SESSION_DIR / f'{self.session_id}.json'
def save(self) -> None:
self.updated_at = time.time()
self.path.write_text(
json.dumps(self.__dict__, ensure_ascii=False, indent=2),
encoding='utf-8',
)
_write_session_index()
def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
if touch_updated_at:
self.updated_at = time.time()
# Write metadata fields first so load_metadata_only() can read them
# without parsing the full messages array (which may be 400KB+).
# Fields are listed in the order they should appear in the JSON file.
METADATA_FIELDS = [
'session_id', 'title', 'workspace', 'model', 'created_at', 'updated_at',
'pinned', 'archived', 'project_id', 'profile',
'input_tokens', 'output_tokens', 'estimated_cost',
'personality', 'active_stream_id',
'pending_user_message', 'pending_attachments', 'pending_started_at',
'compression_anchor_visible_idx', 'compression_anchor_message_key',
]
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
meta['messages'] = self.messages
meta['tool_calls'] = self.tool_calls
# Fields not in METADATA_FIELDS (e.g. last_usage, message_count) go at the end
extra = {k: v for k, v in self.__dict__.items()
if k not in METADATA_FIELDS and k not in ('messages', 'tool_calls')
and not k.startswith('_')}
payload = json.dumps({**meta, **extra}, ensure_ascii=False, indent=2)
tmp = self.path.with_suffix(f'.tmp.{os.getpid()}.{threading.current_thread().ident}')
try:
with open(tmp, 'w', encoding='utf-8') as f:
f.write(payload)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, self.path)
except Exception:
try:
tmp.unlink(missing_ok=True)
except Exception:
pass
raise
if not skip_index:
_write_session_index(updates=[self])
@classmethod
def load(cls, sid):
@@ -81,15 +394,52 @@ class Session:
return None
return cls(**json.loads(p.read_text(encoding='utf-8')))
def compact(self) -> dict:
@classmethod
def load_metadata_only(cls, sid):
"""Load only the compact metadata fields, skipping the messages array.
Session JSON files have metadata fields (session_id, title, model, etc.)
at the top level, before the large messages array. Read only up to the
top-level "messages" field and synthesize a small metadata-only object.
Falls back to load() for legacy or unexpected file layouts.
"""
if not sid or not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid):
return None
p = SESSION_DIR / f'{sid}.json'
if not p.exists():
return None
try:
prefix = _read_metadata_json_prefix(p)
if not prefix:
return cls.load(sid)
parsed = json.loads(prefix)
needed = {'session_id', 'title', 'created_at', 'updated_at'}
if not needed.issubset(parsed.keys()):
return cls.load(sid)
parsed['messages'] = []
parsed['tool_calls'] = []
session = cls(**parsed)
session._metadata_message_count = _lookup_index_message_count(sid)
return session
except Exception:
# Corrupt prefix or decode error — fall back to full load
return cls.load(sid)
def compact(self, include_runtime=False, active_stream_ids=None) -> dict:
active_stream_ids = active_stream_ids if active_stream_ids is not None else set()
return {
'session_id': self.session_id,
'title': self.title,
'workspace': self.workspace,
'model': self.model,
'message_count': len(self.messages),
'message_count': (
self._metadata_message_count
if self._metadata_message_count is not None
else len(self.messages)
),
'created_at': self.created_at,
'updated_at': self.updated_at,
'last_message_at': _last_message_timestamp(self.messages) or self.updated_at,
'pinned': self.pinned,
'archived': self.archived,
'project_id': self.project_id,
@@ -98,14 +448,32 @@ class Session:
'output_tokens': self.output_tokens,
'estimated_cost': self.estimated_cost,
'personality': self.personality,
'compression_anchor_visible_idx': self.compression_anchor_visible_idx,
'compression_anchor_message_key': self.compression_anchor_message_key,
'active_stream_id': self.active_stream_id,
'is_streaming': _is_streaming_session(
self.active_stream_id, active_stream_ids
) if include_runtime else False,
}
def get_session(sid):
def get_session(sid, metadata_only=False):
"""Load a session, optionally with metadata only (skipping the messages array).
Metadata-only loads intentionally do not populate the full-session cache.
Otherwise a later full load could return a compact object with an empty
messages list. Use this when you only need compact() metadata and not the
actual message history (e.g., for fast sidebar switching).
"""
with LOCK:
if sid in SESSIONS:
SESSIONS.move_to_end(sid) # LRU: mark as recently used
return SESSIONS[sid]
s = Session.load(sid)
if metadata_only:
s = Session.load_metadata_only(sid)
if s:
return s
else:
s = Session.load(sid)
if s:
with LOCK:
SESSIONS[sid] = s
@@ -115,14 +483,28 @@ def get_session(sid):
return s
raise KeyError(sid)
def new_session(workspace=None, model=None):
# Use _cfg.DEFAULT_MODEL (not the import-time snapshot) so save_settings() changes take effect
try:
from api.profiles import get_active_profile_name
_profile = get_active_profile_name()
except ImportError:
_profile = None
s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL, profile=_profile)
def new_session(workspace=None, model=None, profile=None):
"""Create a new in-memory session and persist it.
*profile* — when supplied by the caller (e.g. from the request body sent
by the active browser tab), it is used directly so that concurrent clients
on different profiles don't fight over a shared process-global. If not
supplied, we fall back to the process-level active profile (the pre-#798
behaviour, preserved for calls that originate outside a request context).
"""
if profile is None:
# Fallback: read process-level global (single-client or startup path)
try:
from api.profiles import get_active_profile_name
profile = get_active_profile_name()
except ImportError:
profile = None
effective_model = model or get_effective_default_model()
s = Session(
workspace=workspace or get_last_workspace(),
model=effective_model,
profile=profile,
)
with LOCK:
SESSIONS[s.session_id] = s
SESSIONS.move_to_end(s.session_id)
@@ -132,18 +514,49 @@ def new_session(workspace=None, model=None):
return s
def all_sessions():
active_stream_ids = _active_stream_ids()
# Phase C: try index first for O(1) read; fall back to full scan
if SESSION_INDEX_FILE.exists():
try:
index = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
index = [
s for s in index
if _index_entry_exists(s.get('session_id'))
]
backfilled = []
for i, s in enumerate(index):
if 'last_message_at' not in s:
full = Session.load(s.get('session_id'))
if full:
index[i] = full.compact()
backfilled.append(full)
if backfilled:
try:
_write_session_index(updates=backfilled)
except Exception:
logger.debug("Failed to persist last_message_at backfill")
for s in index:
s['is_streaming'] = _is_streaming_session(
s.get('active_stream_id'),
active_stream_ids,
)
# Overlay any in-memory sessions that may be newer than the index
index_map = {s['session_id']: s for s in index}
with LOCK:
for s in SESSIONS.values():
index_map[s.session_id] = s.compact()
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True)
index_map[s.session_id] = s.compact(
include_runtime=True,
active_stream_ids=active_stream_ids,
)
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), _session_sort_timestamp(s)), reverse=True)
# Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.)
result = [s for s in result if not (s.get('title','Untitled')=='Untitled' and s.get('message_count',0)==0)]
# Exempt sessions younger than 60 s so a brand-new session stays visible (#789)
_now = time.time()
result = [s for s in result if not (
s.get('title', 'Untitled') == 'Untitled'
and s.get('message_count', 0) == 0
and (_now - s.get('updated_at', _now)) > 60
)]
# Backfill: sessions created before Sprint 22 have no profile tag.
# Attribute them to 'default' so the client profile filter works correctly.
for s in result:
@@ -151,7 +564,7 @@ def all_sessions():
s['profile'] = 'default'
return result
except Exception:
pass # fall through to full scan
logger.debug("Failed to load session index, falling back to full scan")
# Full scan fallback
out = []
for p in SESSION_DIR.glob('*.json'):
@@ -160,11 +573,16 @@ def all_sessions():
s = Session.load(p.stem)
if s: out.append(s)
except Exception:
pass
logger.debug("Failed to load session from %s", p)
for s in SESSIONS.values():
if all(s.session_id != x.session_id for x in out): out.append(s)
out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True)
result = [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
out.sort(key=lambda s: (getattr(s, 'pinned', False), _session_sort_timestamp(s)), reverse=True)
_now = time.time()
result = [s.compact(include_runtime=True, active_stream_ids=active_stream_ids) for s in out if not (
s.title == 'Untitled'
and len(s.messages) == 0
and (_now - s.updated_at) > 60
)]
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
@@ -200,7 +618,15 @@ def save_projects(projects) -> None:
PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8')
def import_cli_session(session_id: str, title: str, messages, model: str='unknown', profile=None):
def import_cli_session(
session_id: str,
title: str,
messages,
model: str='unknown',
profile=None,
created_at=None,
updated_at=None,
):
"""Create a new WebUI session populated with CLI messages.
Returns the Session object.
"""
@@ -211,8 +637,10 @@ def import_cli_session(session_id: str, title: str, messages, model: str='unknow
model=model,
messages=messages,
profile=profile,
created_at=created_at,
updated_at=updated_at,
)
s.save()
s.save(touch_updated_at=False)
return s
@@ -222,16 +650,11 @@ def get_cli_sessions() -> list:
"""Read CLI sessions from the agent's SQLite store and return them as
dicts in a format the WebUI sidebar can render alongside local sessions.
Returns empty list if the SQLite DB is missing, the sqlite3 module is
unavailable, or any error occurs -- the bridge is purely additive and never
crashes the WebUI.
Returns empty list if the SQLite DB is missing or any error occurs -- the
bridge is purely additive and never crashes the WebUI.
"""
import os
cli_sessions = []
try:
import sqlite3
except ImportError:
return cli_sessions
# Use the active WebUI profile's HERMES_HOME to find state.db.
# The active profile is determined by what the user has selected in the UI
@@ -260,46 +683,56 @@ def get_cli_sessions() -> list:
_cli_profile = None # older agent -- fall back to no profile
try:
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("""
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
MAX(m.timestamp) AS last_activity
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE s.source IS NOT NULL AND s.source != 'webui'
GROUP BY s.id
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
LIMIT 200
""")
for row in cur.fetchall():
sid = row['id']
raw_ts = row['last_activity'] or row['started_at']
# Prefer the CLI session's own profile from the DB; fall back to
# the active CLI profile so sidebar filtering works either way.
profile = _cli_profile # CLI DB has no profile column; use active profile
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger):
sid = row['id']
raw_ts = row['last_activity'] or row['started_at']
# Prefer the CLI session's own profile from the DB; fall back to
# the active CLI profile so sidebar filtering works either way.
profile = _cli_profile # CLI DB has no profile column; use active profile
_source = row['source'] or 'cli'
_display_title = row['title'] or f'{_source.title()} Session'
cli_sessions.append({
'session_id': sid,
'title': _display_title,
'workspace': str(get_last_workspace()),
'model': row['model'] or 'unknown',
'message_count': row['message_count'] or 0,
'created_at': row['started_at'],
'updated_at': raw_ts,
'pinned': False,
'archived': False,
'project_id': None,
'profile': profile,
'source_tag': _source,
'is_cli_session': True,
})
except Exception:
# DB schema changed, locked, or corrupted -- silently degrade
_source = row['source'] or 'cli'
_title = row['title']
if not _title and _source == 'cron' and sid.startswith('cron_'):
# Extract job_id from session ID (cron_{job_id}_{timestamp})
# and look up the human-friendly job name from jobs.json
parts = sid.split('_')
if len(parts) >= 3:
_job_id = parts[1]
try:
_jobs_path = hermes_home / 'cron' / 'jobs.json'
if _jobs_path.exists():
import json as _json
_jobs_data = _json.loads(_jobs_path.read_text())
for _j in _jobs_data.get('jobs', []):
if _j.get('id') == _job_id:
_title = _j.get('name') or _title
break
except Exception:
pass # degrade gracefully
_display_title = _title or f'{_source.title()} Session'
cli_sessions.append({
'session_id': sid,
'title': _display_title,
'workspace': str(get_last_workspace()),
'model': row['model'] or None,
'message_count': row['message_count'] or row['actual_message_count'] or 0,
'created_at': row['started_at'],
'updated_at': raw_ts,
'pinned': False,
'archived': False,
'project_id': None,
'profile': profile,
'source_tag': _source,
'is_cli_session': True,
})
except Exception as _cli_err:
# DB schema changed, locked, or corrupted -- log warning so admins can diagnose.
# Still degrade gracefully (don't crash the WebUI).
import logging as _logging
_logging.getLogger(__name__).warning(
"get_cli_sessions() failed — check state.db schema or path (%s): %s",
db_path, _cli_err,
)
return []
return cli_sessions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import os
from pathlib import Path
from urllib.parse import urlparse
@@ -24,8 +25,11 @@ from api.config import (
)
from api.workspace import get_last_workspace, load_workspaces
logger = logging.getLogger(__name__)
_SUPPORTED_PROVIDER_SETUPS = {
# ── Easy start ──────────────────────────────────────────────────────
"openrouter": {
"label": "OpenRouter",
"env_var": "OPENROUTER_API_KEY",
@@ -34,6 +38,8 @@ _SUPPORTED_PROVIDER_SETUPS = {
"models": [
{"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS
],
"category": "easy_start",
"quick": True,
},
"anthropic": {
"label": "Anthropic",
@@ -41,6 +47,7 @@ _SUPPORTED_PROVIDER_SETUPS = {
"default_model": "claude-sonnet-4.6",
"requires_base_url": False,
"models": list(_PROVIDER_MODELS.get("anthropic", [])),
"category": "easy_start",
},
"openai": {
"label": "OpenAI",
@@ -49,6 +56,26 @@ _SUPPORTED_PROVIDER_SETUPS = {
"default_base_url": "https://api.openai.com/v1",
"requires_base_url": False,
"models": list(_PROVIDER_MODELS.get("openai", [])),
"category": "easy_start",
},
# ── Open / self-hosted ─────────────────────────────────────────────
"ollama": {
"label": "Ollama",
"env_var": "OLLAMA_API_KEY",
"default_model": "qwen3:32b",
"default_base_url": "http://localhost:11434/v1",
"requires_base_url": True,
"models": [],
"category": "self_hosted",
},
"lmstudio": {
"label": "LM Studio",
"env_var": "LMSTUDIO_API_KEY",
"default_model": "gpt-4o-mini",
"default_base_url": "http://localhost:1234/v1",
"requires_base_url": True,
"models": [],
"category": "self_hosted",
},
"custom": {
"label": "Custom OpenAI-compatible",
@@ -56,9 +83,59 @@ _SUPPORTED_PROVIDER_SETUPS = {
"default_model": "gpt-4o-mini",
"requires_base_url": True,
"models": [],
"category": "self_hosted",
},
# ── Specialized / extended ──────────────────────────────────────────
"gemini": {
"label": "Google Gemini",
"env_var": "GOOGLE_API_KEY",
"default_model": "gemini-3.1-pro-preview",
"default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"requires_base_url": False,
# _PROVIDER_MODELS in api/config.py is keyed under "google" even though
# the agent's alias map normalizes "google" → "gemini". Use the catalog
# key here so the wizard surfaces the actual model list.
"models": list(_PROVIDER_MODELS.get("google", [])),
"category": "specialized",
},
"deepseek": {
"label": "DeepSeek",
"env_var": "DEEPSEEK_API_KEY",
"default_model": "deepseek-chat-v3-0324",
"default_base_url": "https://api.deepseek.com/v1",
"requires_base_url": False,
"models": list(_PROVIDER_MODELS.get("deepseek", [])),
"category": "specialized",
},
"mistralai": {
"label": "Mistral",
"env_var": "MISTRAL_API_KEY",
"default_model": "mistral-large-latest",
"default_base_url": "https://api.mistral.ai/v1",
"requires_base_url": False,
# No catalog entry for mistralai today — wizard shows a free-text input.
"models": list(_PROVIDER_MODELS.get("mistralai", [])),
"category": "specialized",
},
"x-ai": {
"label": "xAI (Grok)",
"env_var": "XAI_API_KEY",
"default_model": "grok-4.20",
"default_base_url": "https://api.x.ai/v1",
"requires_base_url": False,
# Agent normalizes "x-ai" → "xai"; _PROVIDER_MODELS is also keyed "xai"
# when populated, so check both keys for forward-compatibility.
"models": list(_PROVIDER_MODELS.get("xai", []) or _PROVIDER_MODELS.get("x-ai", [])),
"category": "specialized",
},
}
_PROVIDER_CATEGORIES = [
{"id": "easy_start", "label": "Easy start", "order": 0},
{"id": "self_hosted", "label": "Open / self-hosted", "order": 1},
{"id": "specialized", "label": "Specialized", "order": 2},
]
_UNSUPPORTED_PROVIDER_NOTE = (
"OAuth and advanced provider flows such as Nous Portal, OpenAI Codex, and GitHub "
"Copilot are still terminal-first. Use `hermes model` for those flows."
@@ -207,6 +284,43 @@ def _provider_api_key_present(
and str(custom_cfg.get("api_key") or "").strip()
):
return True
# For providers not in _SUPPORTED_PROVIDER_SETUPS (e.g. minimax-cn, deepseek,
# xai, etc.), ask the hermes_cli auth registry — it knows every provider's env
# var names and can check os.environ for a valid key.
# Exclude known OAuth/token-flow providers — those are handled separately by
# _provider_oauth_authenticated() and should not be short-circuited here.
_known_oauth = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"}
if provider not in _SUPPORTED_PROVIDER_SETUPS and provider not in _known_oauth:
try:
from hermes_cli.auth import get_auth_status as _gas
status = _gas(provider)
if isinstance(status, dict) and status.get("logged_in"):
return True
except Exception:
pass
return False
def _oauth_payload_has_token(payload: dict) -> bool:
"""Return True if an auth payload contains usable token material."""
if not isinstance(payload, dict):
return False
token_fields = (
payload,
payload.get("tokens") if isinstance(payload.get("tokens"), dict) else {},
)
for candidate in token_fields:
if not isinstance(candidate, dict):
continue
if any(
str(candidate.get(key) or "").strip()
for key in ("access_token", "refresh_token", "api_key")
):
return True
return False
@@ -214,31 +328,15 @@ def _provider_api_key_present(
def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
"""Return True if the provider has valid OAuth credentials.
Checks via hermes_cli.auth.get_auth_status() when available, then falls
back to reading auth.json directly for the known OAuth provider IDs
(openai-codex, copilot, copilot-acp, qwen-oauth, nous).
This covers users who authenticated via 'hermes auth' or 'hermes model'
but whose provider is not in _SUPPORTED_PROVIDER_SETUPS because it does
not use a plain API key.
Reads the profile-scoped auth.json directly so onboarding respects the
requested Hermes home. Known OAuth providers may store auth either in the
legacy providers[provider_id] singleton state or in credential_pool entries
used by current Hermes runtime auth resolution.
"""
provider = (provider or "").strip().lower()
if not provider:
return False
# Fast path: ask hermes_cli directly — the authoritative source
try:
from hermes_cli.auth import get_auth_status as _gas
status = _gas(provider)
if isinstance(status, dict) and status.get("logged_in"):
return True
except Exception:
pass
# Fallback: parse auth.json ourselves for known OAuth provider IDs.
# Covers deployments where hermes_cli is installed but the import above
# fails for an unexpected reason (version mismatch, import cycle, etc.).
_known_oauth_providers = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"}
if provider not in _known_oauth_providers:
return False
@@ -250,20 +348,20 @@ def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
if not auth_path.exists():
return False
store = _j.loads(auth_path.read_text(encoding="utf-8"))
providers_store = store.get("providers")
if not isinstance(providers_store, dict):
return False
state = providers_store.get(provider)
if not isinstance(state, dict):
return False
# Any non-empty token is enough to confirm the user has credentials.
# Token refresh happens at runtime inside the agent.
has_token = bool(
str(state.get("access_token") or "").strip()
or str(state.get("api_key") or "").strip()
or str(state.get("refresh_token") or "").strip()
)
return has_token
if isinstance(providers_store, dict):
state = providers_store.get(provider)
if _oauth_payload_has_token(state):
return True
pool_store = store.get("credential_pool")
if isinstance(pool_store, dict):
entries = pool_store.get(provider)
if isinstance(entries, list):
return any(_oauth_payload_has_token(entry) for entry in entries)
return False
except Exception:
return False
@@ -285,11 +383,13 @@ def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict:
elif provider in _SUPPORTED_PROVIDER_SETUPS:
provider_ready = _provider_api_key_present(provider, cfg, env_values)
else:
# Unknown / OAuth provider (e.g. openai-codex, copilot, qwen-oauth).
# These do not use a plain API key; auth lives in auth.json or a
# credential pool managed by hermes_cli.
provider_ready = _provider_oauth_authenticated(
provider, _get_active_hermes_home()
# Unknown provider — may be an OAuth flow (openai-codex, copilot, etc.)
# OR an API-key provider not in the quick-setup list (minimax-cn, deepseek,
# xai, etc.). Check both: api key presence first (covers the majority of
# third-party providers), then OAuth auth.json.
provider_ready = (
_provider_api_key_present(provider, cfg, env_values)
or _provider_oauth_authenticated(provider, _get_active_hermes_home())
)
chat_ready = bool(_HERMES_FOUND and imports_ok and provider_ready)
@@ -358,10 +458,24 @@ def _build_setup_catalog(cfg: dict) -> dict:
"default_base_url": meta.get("default_base_url") or "",
"requires_base_url": bool(meta.get("requires_base_url")),
"models": list(meta.get("models", [])),
"quick": provider_id == "openrouter",
"category": meta.get("category", "easy_start"),
"quick": meta.get("quick", False),
}
)
# Sort providers by category order, then alphabetically within each category.
cat_order = {c["id"]: c["order"] for c in _PROVIDER_CATEGORIES}
providers.sort(key=lambda p: (cat_order.get(p["category"], 99), p["label"]))
# Group providers by category for the frontend.
categories = []
for cat in sorted(_PROVIDER_CATEGORIES, key=lambda c: c["order"]):
categories.append({
"id": cat["id"],
"label": cat["label"],
"providers": [p["id"] for p in providers if p["category"] == cat["id"]],
})
# Flag whether the currently-configured provider is OAuth-based (not in the
# API-key flow). The frontend uses this to show a confirmation card instead
# of a key input when the user has already authenticated via 'hermes auth'.
@@ -371,6 +485,7 @@ def _build_setup_catalog(cfg: dict) -> dict:
return {
"providers": providers,
"categories": categories,
"unsupported_note": _UNSUPPORTED_PROVIDER_NOTE,
"current_is_oauth": current_is_oauth,
"current": {
@@ -395,14 +510,63 @@ def get_onboarding_status() -> dict:
# HERMES_WEBUI_SKIP_ONBOARDING=1 lets hosting providers (e.g. Agent37) ship
# a pre-configured instance without the wizard blocking the first load.
# Only takes effect when the system is actually chat_ready — a misconfigured
# deployment still shows the wizard so the user can fix it.
# This is an operator-level override and is honoured unconditionally —
# the operator knows their deployment is configured; we must not second-guess
# it by requiring chat_ready to also be true.
skip_env = os.environ.get("HERMES_WEBUI_SKIP_ONBOARDING", "").strip()
skip_requested = skip_env in {"1", "true", "yes"}
auto_completed = skip_requested and bool(runtime.get("chat_ready"))
auto_completed = skip_requested # unconditional: operator says skip, we skip
# Auto-complete for existing Hermes users: if config.yaml already exists
# AND the provider is configured (or the system is chat_ready), treat onboarding
# as done. These users configured Hermes via the CLI before the Web UI existed;
# they must never be shown the first-run wizard — it would silently overwrite their
# config. We use provider_configured (not chat_ready) so that users with
# non-wizard providers (ollama-cloud, deepseek, xai, kimi, etc.) are not forced
# through the wizard just because their provider doesn't have a detectable API key
# — the wizard cannot represent their provider and would overwrite their config
# with whichever wizard-supported provider they accidentally select.
config_exists = Path(_get_config_path()).exists()
# For providers not in the wizard's quick-setup list (e.g. ollama-cloud, deepseek,
# xai, kimi-k2.6), the wizard can never help — it only knows how to configure
# openrouter/anthropic/openai/google/custom. If such a user has a configured
# provider + model in config.yaml, showing the wizard would only confuse them
# (or worse, let them accidentally overwrite their config with gpt-5.4-mini).
_current_provider = str(
(cfg.get("model", {}) or {}).get("provider", "") if isinstance(cfg.get("model"), dict)
else ""
).strip().lower()
_is_non_wizard_provider = bool(
_current_provider and _current_provider not in _SUPPORTED_PROVIDER_SETUPS
)
config_auto_completed = config_exists and (
bool(runtime.get("chat_ready"))
or (_is_non_wizard_provider and bool(runtime.get("provider_configured")))
)
# Persist the flag so it survives future transient import failures (e.g. after
# a git branch switch in the hermes-agent repo). Without this, a CLI-configured
# user who never ran the wizard has no onboarding_completed flag — any momentary
# imports_ok=False during restart makes chat_ready=False, config_auto_completed=False,
# and the wizard reappears with a broken dropdown that clobbers their config.
#
# Best-effort: if save_settings raises (read-only FS, disk full, permission error),
# log and continue. The `config_auto_completed` branch of `completed=` below still
# returns True for this request, so the user sees the correct state — only the
# persistence-across-restart guarantee is degraded. Raising here would turn every
# /api/onboarding/status call into a 500 until disk was writable, which is worse UX
# than losing the next-restart protection.
if config_auto_completed and not settings.get("onboarding_completed"):
try:
save_settings({"onboarding_completed": True})
settings["onboarding_completed"] = True
except Exception:
logger.debug("Failed to persist onboarding_completed", exc_info=True)
return {
"completed": bool(settings.get("onboarding_completed")) or auto_completed,
"completed": bool(settings.get("onboarding_completed")) or auto_completed or config_auto_completed,
"settings": {
"default_model": settings.get("default_model") or DEFAULT_MODEL,
"default_workspace": settings.get("default_workspace")
@@ -429,6 +593,16 @@ def get_onboarding_status() -> dict:
def apply_onboarding_setup(body: dict) -> dict:
# Hard guard: if the operator set SKIP_ONBOARDING, the wizard should never
# have appeared. Even if the frontend somehow calls this endpoint anyway
# (e.g. a stale JS bundle or a curious user), we must not overwrite the
# operator's config.yaml or .env files. Just mark onboarding complete and
# return the current status — no file writes.
skip_env = os.environ.get("HERMES_WEBUI_SKIP_ONBOARDING", "").strip()
if skip_env in {"1", "true", "yes"}:
save_settings({"onboarding_completed": True})
return get_onboarding_status()
provider = str(body.get("provider") or "").strip().lower()
model = str(body.get("model") or "").strip()
api_key = str(body.get("api_key") or "").strip()
@@ -451,7 +625,21 @@ def apply_onboarding_setup(body: dict) -> dict:
if parsed.scheme not in {"http", "https"}:
raise ValueError("base_url must start with http:// or https://")
cfg = _load_yaml_config(_get_config_path())
config_path = _get_config_path()
# Guard: if config.yaml already exists and the caller did not explicitly
# acknowledge the overwrite, refuse to proceed. The frontend must pass
# confirm_overwrite=True after showing the user a confirmation step.
if Path(config_path).exists() and not body.get("confirm_overwrite"):
return {
"error": "config_exists",
"message": (
"Hermes is already configured (config.yaml exists). "
"Pass confirm_overwrite=true to overwrite it."
),
"requires_confirm": True,
}
cfg = _load_yaml_config(config_path)
env_path = _get_active_hermes_home() / ".env"
env_values = _load_env_file(env_path)
@@ -465,17 +653,15 @@ def apply_onboarding_setup(body: dict) -> dict:
model_cfg["provider"] = provider
model_cfg["default"] = _normalize_model_for_provider(provider, model)
if provider == "custom":
if provider_meta.get("requires_base_url"):
model_cfg["base_url"] = base_url
elif provider == "openai":
model_cfg["base_url"] = (
provider_meta.get("default_base_url") or "https://api.openai.com/v1"
)
elif provider_meta.get("default_base_url"):
model_cfg["base_url"] = provider_meta["default_base_url"]
else:
model_cfg.pop("base_url", None)
cfg["model"] = model_cfg
_save_yaml_config(_get_config_path(), cfg)
_save_yaml_config(config_path, cfg)
if api_key:
_write_env_file(env_path, {provider_meta["env_var"]: api_key})
@@ -486,7 +672,7 @@ def apply_onboarding_setup(body: dict) -> dict:
from api.profiles import _reload_dotenv
_reload_dotenv(_get_active_hermes_home())
except Exception:
pass
logger.debug("Failed to reload dotenv")
# Belt-and-braces: set directly on os.environ AFTER _reload_dotenv so the
# value survives even if _reload_dotenv cleared it (e.g. when _write_env_file
@@ -499,7 +685,7 @@ def apply_onboarding_setup(body: dict) -> dict:
from hermes_cli.config import reload as _cli_reload
_cli_reload()
except Exception:
pass
logger.debug("Failed to reload hermes_cli config")
reload_config()
return get_onboarding_status()

View File

@@ -9,12 +9,15 @@ cached paths in hermes-agent modules (skills_tool, cron/jobs) that snapshot
HERMES_HOME at import time.
"""
import json
import logging
import os
import re
import shutil
import threading
from pathlib import Path
logger = logging.getLogger(__name__)
# ── Constants (match hermes_cli.profiles upstream) ─────────────────────────
_PROFILE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
_PROFILE_DIRS = [
@@ -28,6 +31,12 @@ _active_profile = 'default'
_profile_lock = threading.Lock()
_loaded_profile_env_keys: set[str] = set()
# Thread-local profile context: set per-request by server.py, cleared after.
# Enables per-client profile isolation (issue #798) — each HTTP request thread
# reads its own profile from the hermes_profile cookie instead of the
# process-global _active_profile.
_tls = threading.local()
def _resolve_base_hermes_home() -> Path:
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
@@ -72,26 +81,78 @@ def _read_active_profile_file() -> str:
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
if ap_file.exists():
try:
name = ap_file.read_text().strip()
name = ap_file.read_text(encoding="utf-8").strip()
if name:
return name
except Exception:
pass
logger.debug("Failed to read active profile file")
return 'default'
# ── Public API ──────────────────────────────────────────────────────────────
def get_active_profile_name() -> str:
"""Return the currently active profile name."""
"""Return the currently active profile name.
Priority:
1. Thread-local (set per-request from hermes_profile cookie) — issue #798
2. Process-level default (_active_profile)
"""
tls_name = getattr(_tls, 'profile', None)
if tls_name is not None:
return tls_name
return _active_profile
def set_request_profile(name: str) -> None:
"""Set the per-request profile context for this thread.
Called by server.py at the start of each request when a hermes_profile
cookie is present. Always paired with clear_request_profile() in a
finally block so the thread-local is released after the request.
"""
_tls.profile = name
def clear_request_profile() -> None:
"""Clear the per-request profile context for this thread.
Called by server.py in the finally block of do_GET / do_POST.
Safe to call even if set_request_profile() was never called.
"""
_tls.profile = None
def get_active_hermes_home() -> Path:
"""Return the HERMES_HOME path for the currently active profile."""
if _active_profile == 'default':
"""Return the HERMES_HOME path for the currently active profile.
Uses get_active_profile_name() so per-request TLS context (issue #798)
is respected, not just the process-level global.
"""
name = get_active_profile_name()
if name == 'default':
return _DEFAULT_HERMES_HOME
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
if profile_dir.is_dir():
return profile_dir
return _DEFAULT_HERMES_HOME
def get_hermes_home_for_profile(name: str) -> Path:
"""Return the HERMES_HOME Path for *name* without mutating any process state.
Safe to call from per-request context (streaming, session creation) because
it reads only the filesystem — it never touches os.environ, module-level
cached paths, or the process-level _active_profile global.
Falls back to _DEFAULT_HERMES_HOME (same as 'default') when *name* is None,
empty, 'default', or does not match the profile-name format (rejects path
traversal such as '../../etc').
"""
if not name or name == 'default' or not _PROFILE_ID_RE.match(name):
return _DEFAULT_HERMES_HOME
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
if profile_dir.is_dir():
return profile_dir
return _DEFAULT_HERMES_HOME
@@ -107,7 +168,7 @@ def _set_hermes_home(home: Path):
_sk.HERMES_HOME = home
_sk.SKILLS_DIR = home / 'skills'
except (ImportError, AttributeError):
pass
logger.debug("Failed to patch skills_tool module")
# Patch cron/jobs module-level cache
try:
@@ -117,7 +178,7 @@ def _set_hermes_home(home: Path):
_cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json'
_cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
except (ImportError, AttributeError):
pass
logger.debug("Failed to patch cron.jobs module")
def _reload_dotenv(home: Path):
@@ -139,7 +200,7 @@ def _reload_dotenv(home: Path):
return
try:
loaded_keys: set[str] = set()
for line in env_path.read_text().splitlines():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
@@ -151,6 +212,7 @@ def _reload_dotenv(home: Path):
_loaded_profile_env_keys = loaded_keys
except Exception:
_loaded_profile_env_keys = set()
logger.debug("Failed to reload dotenv from %s", env_path)
def init_profile_state() -> None:
@@ -166,12 +228,18 @@ def init_profile_state() -> None:
_reload_dotenv(home)
def switch_profile(name: str) -> dict:
def switch_profile(name: str, *, process_wide: bool = True) -> dict:
"""Switch the active profile.
Validates the profile exists, updates process state, patches module caches,
reloads .env, and reloads config.yaml.
Args:
name: Profile name to switch to.
process_wide: If True (default), updates the process-global
_active_profile. Set to False for per-client switches from the
WebUI where the profile is managed via cookie + thread-local (#798).
Returns: {'profiles': [...], 'active': name}
Raises ValueError if profile doesn't exist or agent is busy.
"""
@@ -192,29 +260,46 @@ def switch_profile(name: str) -> dict:
if name == 'default':
home = _DEFAULT_HERMES_HOME
else:
home = _DEFAULT_HERMES_HOME / 'profiles' / name
home = _resolve_named_profile_home(name)
if not home.is_dir():
raise ValueError(f"Profile '{name}' does not exist.")
with _profile_lock:
_active_profile = name
_set_hermes_home(home)
_reload_dotenv(home)
if process_wide:
global _active_profile
_active_profile = name
_set_hermes_home(home)
_reload_dotenv(home)
# Write sticky default for CLI consistency
try:
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
ap_file.write_text(name if name != 'default' else '')
except Exception:
pass
if process_wide:
# Write sticky default for CLI consistency
try:
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
ap_file.write_text(name if name != 'default' else '', encoding='utf-8')
except Exception:
logger.debug("Failed to write active profile file")
# Reload config.yaml from the new profile
reload_config()
# Reload config.yaml from the new profile
reload_config()
# Return profile-specific defaults so frontend can apply them
# Return profile-specific defaults so frontend can apply them.
# For process_wide=False (per-client switch), read the target profile's
# config.yaml directly from disk rather than from _cfg_cache (process-global),
# since reload_config() was intentionally skipped.
from api.workspace import get_last_workspace
from api.config import get_config
cfg = get_config()
if process_wide:
from api.config import get_config
cfg = get_config()
else:
# Direct disk read — does not touch _cfg_cache
try:
import yaml as _yaml
cfg_path = home / 'config.yaml'
cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {}
if not isinstance(cfg, dict):
cfg = {}
except Exception:
cfg = {}
model_cfg = cfg.get('model', {})
default_model = None
if isinstance(model_cfg, str):
@@ -239,7 +324,7 @@ def list_profiles_api() -> list:
# hermes_cli not available -- return just the default
return [_default_profile_dict()]
active = _active_profile
active = get_active_profile_name()
result = []
for p in infos:
result.append({
@@ -283,6 +368,24 @@ def _validate_profile_name(name: str):
)
def _profiles_root() -> Path:
"""Return the canonical root that contains named profiles."""
return (_DEFAULT_HERMES_HOME / 'profiles').resolve()
def _resolve_named_profile_home(name: str) -> Path:
"""Resolve a named profile to a directory under the profiles root.
Validates *name* as a logical profile identifier first, then resolves the
final filesystem path and enforces containment under ~/.hermes/profiles.
"""
_validate_profile_name(name)
profiles_root = _profiles_root()
candidate = (profiles_root / name).resolve()
candidate.relative_to(profiles_root)
return candidate
def _create_profile_fallback(name: str, clone_from: str = None,
clone_config: bool = False) -> Path:
"""Create a profile directory without hermes_cli (Docker/standalone fallback)."""
@@ -322,11 +425,11 @@ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key:
cfg = {}
if config_path.exists():
try:
loaded = _yaml.safe_load(config_path.read_text())
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
cfg = loaded
except Exception:
pass
logger.debug("Failed to load config from %s", config_path)
model_section = cfg.get('model', {})
if not isinstance(model_section, dict):
model_section = {}
@@ -335,7 +438,7 @@ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key:
if api_key:
model_section['api_key'] = api_key
cfg['model'] = model_section
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True))
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
def create_profile_api(name: str, clone_from: str = None,
@@ -371,7 +474,7 @@ def create_profile_api(name: str, clone_from: str = None,
try:
profile_path = Path(p.get('path') or profile_path)
except Exception:
pass
logger.debug("Failed to parse profile path")
break
profile_path.mkdir(parents=True, exist_ok=True)
@@ -401,6 +504,7 @@ def delete_profile_api(name: str) -> dict:
"""Delete a profile. Switches to default first if it's the active one."""
if name == 'default':
raise ValueError("Cannot delete the default profile.")
_validate_profile_name(name)
# If deleting the active profile, switch to default first
if _active_profile == name:
@@ -418,7 +522,7 @@ def delete_profile_api(name: str) -> dict:
except ImportError:
# Manual fallback: just remove the directory
import shutil
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
profile_dir = _resolve_named_profile_home(name)
if profile_dir.is_dir():
shutil.rmtree(str(profile_dir))
else:

331
api/providers.py Normal file
View File

@@ -0,0 +1,331 @@
"""Hermes Web UI -- provider management endpoints.
Provides CRUD operations for configuring provider API keys post-onboarding.
Closes #586 (allow provider key update) and part of #604 (model picker
multi-provider support).
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
from api.config import (
_PROVIDER_DISPLAY,
_PROVIDER_MODELS,
get_config,
invalidate_models_cache,
)
logger = logging.getLogger(__name__)
# SECTION: Provider ↔ env var mapping
# Maps canonical provider slug → env var name for API key.
# Providers not listed here (OAuth/token-flow providers like copilot, nous,
# openai-codex) cannot have their keys managed from the WebUI.
_PROVIDER_ENV_VAR: dict[str, str] = {
"openrouter": "OPENROUTER_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"openai": "OPENAI_API_KEY",
"google": "GOOGLE_API_KEY",
"gemini": "GEMINI_API_KEY",
"zai": "GLM_API_KEY",
"kimi-coding": "KIMI_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"minimax": "MINIMAX_API_KEY",
"mistralai": "MISTRAL_API_KEY",
"x-ai": "XAI_API_KEY",
"opencode-zen": "OPENCODE_ZEN_API_KEY",
"opencode-go": "OPENCODE_GO_API_KEY",
"ollama": "OLLAMA_API_KEY",
"ollama-cloud": "OLLAMA_API_KEY",
}
# Providers that use OAuth or token flows — their credentials are managed
# through the Hermes CLI, not via API keys. The WebUI cannot set these.
_OAUTH_PROVIDERS = frozenset({
"copilot",
"openai-codex",
"nous",
})
# SECTION: Helper functions
def _get_hermes_home() -> Path:
"""Return the active Hermes home directory."""
try:
from api.profiles import get_active_hermes_home
return get_active_hermes_home()
except ImportError:
return Path.home() / ".hermes"
def _load_env_file(env_path: Path) -> dict[str, str]:
"""Read key=value pairs from a .env file."""
values: dict[str, str] = {}
if not env_path.exists():
return values
try:
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
except Exception:
return {}
return values
def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
"""Write key=value pairs to the .env file.
Values of ``None`` cause the key to be removed.
Holds ``_ENV_LOCK`` from ``api.streaming`` for the entire load → modify →
write cycle to prevent TOCTOU races between concurrent POST /api/providers
calls (each reading the same file baseline and overwriting the other's key).
Also serialises os.environ mutations with streaming sessions.
"""
from api.streaming import _ENV_LOCK
import stat as _stat
with _ENV_LOCK:
current = _load_env_file(env_path)
for key, value in updates.items():
if value is None:
current.pop(key, None)
os.environ.pop(key, None)
continue
clean = str(value).strip()
if not clean:
continue
# Reject embedded newlines/carriage returns to prevent .env injection
if "\n" in clean or "\r" in clean:
raise ValueError("API key must not contain newline characters.")
current[key] = clean
os.environ[key] = clean
env_path.parent.mkdir(parents=True, exist_ok=True)
lines = [f"{key}={current[key]}" for key in sorted(current)]
# Create at owner-only mode from the first byte (O_CREAT honours the mode
# argument subject to umask). A trailing chmod guards pre-existing files.
_mode = _stat.S_IRUSR | _stat.S_IWUSR # 0o600
_fd = os.open(str(env_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _mode)
with os.fdopen(_fd, "w", encoding="utf-8") as _f:
_f.write("\n".join(lines) + ("\n" if lines else ""))
try:
env_path.chmod(_mode)
except OSError:
pass
def _provider_has_key(provider_id: str) -> bool:
"""Check whether a provider has a configured API key.
Checks (in order):
1. ``~/.hermes/.env`` for the known env var
2. ``os.environ`` for the known env var
3. ``config.yaml → model.api_key``
4. ``config.yaml → providers.<id>.api_key``
5. ``config.yaml → custom_providers[].api_key`` (for custom providers)
"""
env_var = _PROVIDER_ENV_VAR.get(provider_id)
if env_var:
env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path)
if env_values.get(env_var):
return True
if os.getenv(env_var):
return True
cfg = get_config()
# Check model.api_key
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
return True
# Check providers.<id>.api_key
providers_cfg = cfg.get("providers", {})
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(provider_id, {})
if isinstance(provider_cfg, dict) and str(provider_cfg.get("api_key") or "").strip():
return True
# Check custom_providers
custom_providers = cfg.get("custom_providers", [])
if isinstance(custom_providers, list):
for cp in custom_providers:
if isinstance(cp, dict):
cp_name = (cp.get("name") or "").strip().lower().replace(" ", "-")
if f"custom:{cp_name}" == provider_id or cp.get("name", "").strip().lower() == provider_id:
if str(cp.get("api_key") or "").strip():
return True
return False
def _provider_is_oauth(provider_id: str) -> bool:
"""Check whether a provider uses OAuth/token flows (managed by CLI)."""
return provider_id in _OAUTH_PROVIDERS
# SECTION: Public API
def get_providers() -> dict[str, Any]:
"""Return a list of all known providers with their configuration status.
Each entry contains:
- ``id``: canonical provider slug
- ``display_name``: human-readable name
- ``has_key``: whether an API key is configured
- ``configurable``: whether the key can be set from the WebUI
- ``key_source``: where the key was found (``env_file``, ``env_var``,
``config_yaml``, ``oauth``, ``none``)
- ``models``: list of known model IDs for this provider
"""
providers = []
# Collect all known provider IDs from multiple sources
known_ids = set(_PROVIDER_DISPLAY.keys()) | set(_PROVIDER_MODELS.keys())
# Also detect providers from config.yaml providers section
cfg = get_config()
providers_cfg = cfg.get("providers", {})
if isinstance(providers_cfg, dict):
known_ids.update(providers_cfg.keys())
# Add OAuth providers even if not in _PROVIDER_DISPLAY
known_ids.update(_OAUTH_PROVIDERS)
for pid in sorted(known_ids):
display_name = _PROVIDER_DISPLAY.get(pid, pid.replace("-", " ").title())
is_oauth = _provider_is_oauth(pid)
has_key = _provider_has_key(pid)
# Determine key source
key_source = "none"
if is_oauth:
key_source = "oauth"
# Check if actually authenticated via hermes_cli
try:
from hermes_cli.auth import get_auth_status as _gas
status = _gas(pid)
if isinstance(status, dict) and status.get("logged_in"):
has_key = True
key_source = status.get("key_source", "oauth")
else:
has_key = False
except Exception:
has_key = False
elif has_key:
env_var = _PROVIDER_ENV_VAR.get(pid)
if env_var:
env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path)
if env_values.get(env_var):
key_source = "env_file"
elif os.getenv(env_var):
key_source = "env_var"
else:
key_source = "config_yaml"
else:
key_source = "config_yaml"
models = _PROVIDER_MODELS.get(pid, [])
# Also include models from config.yaml providers section
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(pid, {})
if isinstance(provider_cfg, dict) and "models" in provider_cfg:
cfg_models = provider_cfg["models"]
if isinstance(cfg_models, dict):
models = models + [{"id": k, "label": k} for k in cfg_models.keys()]
elif isinstance(cfg_models, list):
models = models + [{"id": k, "label": k} for k in cfg_models]
providers.append({
"id": pid,
"display_name": display_name,
"has_key": has_key,
"configurable": not is_oauth and pid in _PROVIDER_ENV_VAR,
"key_source": key_source,
"models": models,
})
# Determine active provider
active_provider = None
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
active_provider = model_cfg.get("provider")
return {
"providers": providers,
"active_provider": active_provider,
}
def set_provider_key(provider_id: str, api_key: str | None) -> dict[str, Any]:
"""Set or update the API key for a provider.
Writes the key to ``~/.hermes/.env`` using the standard env var name.
If ``api_key`` is None or empty, the key is removed.
Returns a status dict with the operation result.
"""
provider_id = provider_id.strip().lower()
if not provider_id:
return {"ok": False, "error": "Provider ID is required."}
if _provider_is_oauth(provider_id):
return {
"ok": False,
"error": f"'{_PROVIDER_DISPLAY.get(provider_id, provider_id)}' uses OAuth authentication. "
f"Use `hermes model` in the terminal to configure it.",
}
env_var = _PROVIDER_ENV_VAR.get(provider_id)
if not env_var:
return {
"ok": False,
"error": f"Cannot configure API key for '{_PROVIDER_DISPLAY.get(provider_id, provider_id)}'. "
f"This provider does not have a known env var mapping.",
}
# Validate API key format (basic sanity check)
if api_key:
api_key = api_key.strip()
if "\n" in api_key or "\r" in api_key:
return {"ok": False, "error": "API key must not contain newline characters."}
if len(api_key) < 8:
return {"ok": False, "error": "API key appears too short."}
env_path = _get_hermes_home() / ".env"
try:
_write_env_file(env_path, {env_var: api_key})
except ValueError as exc:
return {"ok": False, "error": str(exc)}
except Exception as exc:
logger.exception("Failed to write env file for provider %s", provider_id)
return {"ok": False, "error": f"Failed to save API key: {exc}"}
# Invalidate the model cache so the dropdown refreshes on next request.
# Using invalidate_models_cache() instead of reload_config() to avoid
# disrupting active streaming sessions that may be reading config.cfg.
invalidate_models_cache()
return {
"ok": True,
"provider": provider_id,
"display_name": _PROVIDER_DISPLAY.get(provider_id, provider_id),
"action": "updated" if api_key else "removed",
}
def remove_provider_key(provider_id: str) -> dict[str, Any]:
"""Remove the API key for a provider.
Convenience wrapper around ``set_provider_key(id, None)``.
"""
return set_provider_key(provider_id, None)

File diff suppressed because it is too large Load Diff

161
api/session_ops.py Normal file
View File

@@ -0,0 +1,161 @@
"""Session-mutation operations for slash commands (/retry, /undo) and
read-only aggregators (/status, /usage). Operates on the webui's own
JSON Session store (api/models.py), not on hermes-agent's SQLite.
Behavior parity reference: gateway/run.py:_handle_*_command in
the hermes-agent repo.
"""
from __future__ import annotations
import logging
from typing import Any
from api.config import LOCK, _get_session_agent_lock
from api.models import get_session, SESSIONS
logger = logging.getLogger(__name__)
def retry_last(session_id: str) -> dict[str, Any]:
"""Truncate the session to before the last user message, return its text.
Mirrors gateway/run.py:_handle_retry_command. Caller (webui frontend)
is expected to put the returned text back in the composer and call
send() to resume the conversation -- the agent's gateway calls its own
_handle_message; the webui has no equivalent in-process pipeline.
Raises:
KeyError: session not found
ValueError: no user message in transcript
"""
# Acquire the per-session agent lock as the outermost lock so that the
# read-modify-write of s.messages is serialised with the periodic
# checkpoint thread, cancel_stream, and all other session writers.
# Lock ordering: _agent_lock → LOCK → _write_session_index (LOCK).
with _get_session_agent_lock(session_id):
# get_session() and Session.save() both acquire the module-level LOCK
# internally (the latter via _write_session_index()), and LOCK is a
# non-reentrant threading.Lock — so they MUST be called outside our
# own `with LOCK:` block to avoid self-deadlocking.
#
# The race we close is the read-modify-write of s.messages: two
# concurrent /api/session/retry calls could otherwise both compute the
# same last_user_idx from the same history and double-truncate. We
# serialize just the in-memory mutation; persistence happens inside
# the per-session lock so the checkpoint thread cannot race us.
#
# Stale-object guard: on a cache miss, two concurrent get_session()
# calls can each load and cache a *different* Session instance for the
# same session_id (the second store clobbers the first). Re-bind to
# the canonical cached instance inside the lock so the mutation lands
# on the object the next reader will see, not a stale parallel copy.
s = get_session(session_id) # raises KeyError if missing
with LOCK:
s = SESSIONS.get(session_id, s)
history = s.messages or []
last_user_idx = None
for i in range(len(history) - 1, -1, -1):
if history[i].get('role') == 'user':
last_user_idx = i
break
if last_user_idx is None:
raise ValueError('No previous message to retry.')
last_user_text = _extract_text(history[last_user_idx].get('content', ''))
removed_count = len(history) - last_user_idx
s.messages = history[:last_user_idx]
s.save()
return {'last_user_text': last_user_text, 'removed_count': removed_count}
def undo_last(session_id: str) -> dict[str, Any]:
"""Remove the most recent user message and everything after it.
Mirrors gateway/run.py:_handle_undo_command. Returns a preview of the
removed text so the UI can confirm to the user.
Raises:
KeyError: session not found
ValueError: no user message in transcript
"""
# Acquire the per-session agent lock as the outermost lock so that the
# read-modify-write of s.messages is serialised with the periodic
# checkpoint thread, cancel_stream, and all other session writers.
# Lock ordering: _agent_lock → LOCK → _write_session_index (LOCK).
with _get_session_agent_lock(session_id):
s = get_session(session_id) # acquires LOCK transiently
with LOCK:
# Stale-object guard — see retry_last for the rationale.
s = SESSIONS.get(session_id, s)
history = s.messages or []
last_user_idx = None
for i in range(len(history) - 1, -1, -1):
if history[i].get('role') == 'user':
last_user_idx = i
break
if last_user_idx is None:
raise ValueError('Nothing to undo.')
removed_text = _extract_text(history[last_user_idx].get('content', ''))
removed_count = len(history) - last_user_idx
s.messages = history[:last_user_idx]
s.save() # outside LOCK -- save() re-acquires LOCK via _write_session_index()
preview = (removed_text[:40] + '...') if len(removed_text) > 40 else removed_text
return {
'removed_count': removed_count,
'removed_preview': preview,
}
def session_status(session_id: str) -> dict[str, Any]:
"""Return a snapshot of session state for /status.
Webui equivalent of gateway/run.py:_handle_status_command. The agent's
"agent_running" comes from `session_key in self._running_agents`; the
webui equivalent is whether the session has an active stream
(active_stream_id is set).
"""
s = get_session(session_id)
return {
'session_id': s.session_id,
'title': s.title,
'model': s.model,
'workspace': s.workspace,
'personality': s.personality,
'message_count': len(s.messages or []),
'created_at': s.created_at,
'updated_at': s.updated_at,
'agent_running': bool(getattr(s, 'active_stream_id', None)),
}
def session_usage(session_id: str) -> dict[str, Any]:
"""Return token usage and cost for /usage.
Mirrors gateway/run.py:_handle_usage_command's basic counters. The
agent shows additional fields (rate-limit headroom etc.) that depend
on provider API responses we don't have in webui -- those are deferred.
"""
s = get_session(session_id)
inp = int(s.input_tokens or 0)
out = int(s.output_tokens or 0)
return {
'input_tokens': inp,
'output_tokens': out,
'total_tokens': inp + out,
'estimated_cost': s.estimated_cost,
'model': s.model,
}
def _extract_text(content: Any) -> str:
"""Flatten message content to plain text. Agent stores either a string
or a list of {type, text|...} parts; webui needs the user-typed text."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for p in content:
if isinstance(p, dict) and p.get('type') == 'text':
parts.append(p.get('text', ''))
return ' '.join(parts)
return str(content)

View File

@@ -41,11 +41,41 @@ def _agent_dir() -> Path | None:
return p.resolve()
return None
def _trusted_agent_dir(agent_dir: Path) -> bool:
"""Return True if agent_dir passes ownership and permission checks.
Validates that the directory is not world- or group-writable and,
on POSIX systems, is owned by the current process user.
Intentionally does NOT enforce a canonical path (i.e. does not require
the dir to be ~/.hermes/hermes-agent), so custom HERMES_WEBUI_AGENT_DIR
paths work correctly when HERMES_WEBUI_AUTO_INSTALL=1 is set.
"""
try:
st = agent_dir.stat()
if stat.S_IMODE(st.st_mode) & 0o022:
# World- or group-writable — untrusted
return False
if hasattr(os, 'getuid') and st.st_uid != os.getuid():
# Not owned by current user (POSIX only; Windows fallback skips)
return False
return True
except OSError:
return False
def auto_install_agent_deps() -> bool:
enabled = os.environ.get('HERMES_WEBUI_AUTO_INSTALL', '').strip().lower() in ('1', 'true', 'yes')
if not enabled:
print('[!!] Auto-install disabled. Set HERMES_WEBUI_AUTO_INSTALL=1 to enable.', flush=True)
return False
agent_dir = _agent_dir()
if agent_dir is None:
print('[!!] Auto-install skipped: agent directory not found.', flush=True)
return False
if not _trusted_agent_dir(agent_dir):
print('[!!] Auto-install skipped: agent directory failed trust check (check ownership/permissions).', flush=True)
return False
req_file = agent_dir / 'requirements.txt'
pyproject = agent_dir / 'pyproject.toml'
if req_file.exists():

View File

@@ -13,9 +13,12 @@ The bridge uses absolute token counts (not deltas) because the WebUI
Session object already accumulates totals across turns. This avoids
any double-counting risk.
"""
import logging
import os
from pathlib import Path
logger = logging.getLogger(__name__)
def _get_state_db():
"""Get a SessionDB instance for the active profile's state.db.
@@ -31,6 +34,7 @@ def _get_state_db():
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
logger.debug("Failed to resolve hermes home, using default")
hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
db_path = hermes_home / 'state.db'
@@ -40,6 +44,7 @@ def _get_state_db():
try:
return SessionDB(db_path)
except Exception:
logger.debug("Failed to open state.db")
return None
@@ -57,12 +62,12 @@ def sync_session_start(session_id: str, model=None) -> None:
model=model,
)
except Exception:
pass # never crash the WebUI for sync failures
logger.debug("Failed to sync session start to state.db")
finally:
try:
db.close()
except Exception:
pass
logger.debug("Failed to close state.db")
def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0,
@@ -92,7 +97,7 @@ def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=
try:
db.set_session_title(session_id, title)
except Exception:
pass
logger.debug("Failed to sync session title to state.db")
# Update message count
if message_count is not None:
try:
@@ -103,11 +108,11 @@ def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=
)
db._execute_write(_set_msg_count)
except Exception:
pass
logger.debug("Failed to sync message count to state.db")
except Exception:
pass # never crash the WebUI for sync failures
logger.debug("Failed to sync session usage to state.db")
finally:
try:
db.close()
except Exception:
pass
logger.debug("Failed to close state.db")

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,48 @@ def _run_git(args, cwd, timeout=10):
return f'git failed to start: {exc}', False
def _detect_webui_version() -> str:
"""Detect the running WebUI version from git or a baked-in fallback file.
Resolution order:
1. ``git describe --tags --always --dirty`` — works in any git checkout.
Returns the exact tag on tagged commits (e.g. ``v0.50.124``), a
post-tag descriptor between releases (e.g. ``v0.50.124-1-ge91325d``),
or a bare SHA when no tags exist (shallow clones, fresh forks).
2. ``api/_version.py`` — a fallback written by the Docker / CI release
workflow when ``.git`` is not present in the image. Expected to define
``__version__ = 'vX.Y.Z'``.
3. ``'unknown'`` — last resort; displayed as-is in the settings badge.
"""
# Timeout capped at 3s: git describe on a healthy local repo is <50ms;
# a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable.
out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], REPO_ROOT, timeout=3)
if ok and out:
return out
# Docker / baked-image fallback: api/_version.py written by CI at build time.
# Parse with regex rather than exec() — the file holds exactly one assignment
# and regex is sufficient; exec() on a build artifact is an unnecessary surface.
version_file = REPO_ROOT / 'api' / '_version.py'
if version_file.exists():
try:
import re as _re
m = _re.search(
r"""__version__\s*=\s*['"]([^'"]+)['"]""",
version_file.read_text(encoding='utf-8'),
)
if m:
return m.group(1)
except Exception:
pass
return 'unknown'
# Resolved once at import time — tags cannot change without a process restart.
WEBUI_VERSION: str = _detect_webui_version()
def _split_remote_ref(ref):
"""Split 'origin/branch-name' into ('origin', 'branch-name').
@@ -141,6 +183,111 @@ def check_for_updates(force=False):
_check_in_progress = False
def _schedule_restart(delay: float = 2.0) -> None:
"""Re-exec this process after *delay* seconds.
Called after a successful update so that the freshly-pulled code is
loaded on the next request, rather than running with a mix of old and
new Python modules in sys.modules.
os.execv() replaces the current process image with a fresh interpreter
running the same argv — sessions are preserved on disk, the HTTP port
is reclaimed within the delay window, and the client's own
``setTimeout(() => location.reload(), 2500)`` lands after the restart.
Coordinates with ``_apply_lock``: when the user updates both webui
and agent, the client POSTs them sequentially. Without coordination
the restart timer scheduled by the first update's success would fire
while the second update's git-pull is still running, killing it mid-
stream and leaving the second repo in an unknown partial state.
Blocking on ``_apply_lock`` before ``os.execv`` means a pending
second update always completes before the restart happens.
"""
import os
import sys
def _do():
import time
time.sleep(delay)
# Hold _apply_lock through os.execv so no new update can start between
# the lock-release and the process replacement. Any in-flight update
# finishes first (since it holds the lock), and then the process is
# replaced while still holding the lock — meaning no new update can
# sneak in during the brief TOCTOU window that existed with the
# original acquire-release-execv sequence.
# Threads die when execv replaces the process image, so the lock is
# released atomically by the kernel.
with _apply_lock:
try:
os.execv(sys.executable, [sys.executable] + sys.argv)
except Exception:
# Last-resort: if execv fails (e.g. frozen binary), just exit
# so the process supervisor (start.sh / Docker) restarts us.
os._exit(0)
threading.Thread(target=_do, daemon=True).start()
def apply_force_update(target: str) -> dict:
"""Force-reset the target repo to the latest remote HEAD.
Unlike apply_update() which requires a clean working tree and refuses
merge conflicts, this discards all local modifications (checkout .) and
resets to origin/<branch> — equivalent to what the diverged/conflict
error messages ask the user to run manually.
Should only be called when apply_update() has already returned a
response with ``conflict: True`` or ``diverged: True`` and the user
has confirmed they want to discard local changes.
"""
if not _apply_lock.acquire(blocking=False):
return {'ok': False, 'message': 'Update already in progress'}
try:
if target == 'webui':
path = REPO_ROOT
elif target == 'agent':
path = _AGENT_DIR
else:
return {'ok': False, 'message': f'Unknown target: {target}'}
if path is None or not (path / '.git').exists():
return {'ok': False, 'message': 'Not a git repository'}
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
if not fetch_ok:
return {
'ok': False,
'message': 'Could not reach the remote repository. Check your connection.',
}
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
compare_ref = upstream
else:
branch = _detect_default_branch(path)
compare_ref = f'origin/{branch}'
# Discard local modifications then reset to remote HEAD
_run_git(['checkout', '.'], path)
_, ok = _run_git(['reset', '--hard', compare_ref], path)
if not ok:
return {'ok': False, 'message': f'Force reset to {compare_ref} failed'}
with _cache_lock:
_update_cache['checked_at'] = 0
_schedule_restart()
return {
'ok': True,
'message': f'{target} force-updated to {compare_ref}',
'target': target,
'restart_scheduled': True,
}
finally:
_apply_lock.release()
def apply_update(target):
"""Stash, pull --ff-only, pop for the given target repo."""
if not _apply_lock.acquire(blocking=False):
@@ -193,7 +340,16 @@ def _apply_update_inner(target):
# Fail early on unresolved merge conflicts
if any(line[:2] in {'DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'}
for line in status_out.splitlines()):
return {'ok': False, 'message': 'Repository has unresolved merge conflicts'}
return {
'ok': False,
'message': (
f'The local {target} repo has unresolved merge conflicts. '
'To reset to the latest remote version run: '
'git -C ' + str(path) + ' checkout . && '
'git -C ' + str(path) + ' pull --ff-only'
),
'conflict': True,
}
stashed = False
if status_out:
_, ok = _run_git(['stash'], path)
@@ -254,4 +410,22 @@ def _apply_update_inner(target):
with _cache_lock:
_update_cache['checked_at'] = 0
return {'ok': True, 'message': f'{target} updated successfully', 'target': target}
# Schedule a self-restart so the updated code is loaded fresh. A plain
# git pull leaves stale Python modules in sys.modules — agent imports that
# reference new symbols (functions, classes) added in the update will fail
# on the next request with AttributeError / ImportError. os.execv() re-
# execs the same interpreter with the same argv, picking up the new code
# cleanly without requiring the user to restart manually.
#
# The 2 s delay gives the HTTP response time to flush to the client before
# the process replaces itself. The client already does
# setTimeout(() => location.reload(), 1500) on success, so the page reload
# and the restart land at roughly the same time.
_schedule_restart()
return {
'ok': True,
'message': f'{target} updated successfully',
'target': target,
'restart_scheduled': True,
}

View File

@@ -3,6 +3,7 @@ Hermes Web UI -- File upload: multipart parser and upload handler.
"""
import re as _re
import email.parser
import tempfile
from pathlib import Path
from api.config import MAX_UPLOAD_BYTES
@@ -50,8 +51,15 @@ def parse_multipart(rfile, content_type, content_length) -> tuple:
return fields, files
def _sanitize_upload_name(filename: str) -> str:
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
if not safe_name or safe_name.strip('.') == '':
raise ValueError('Invalid filename')
return safe_name
def handle_upload(handler):
import re as _re, traceback as _tb
import traceback as _tb
try:
content_type = handler.headers.get('Content-Type', '')
content_length = int(handler.headers.get('Content-Length', 0) or 0)
@@ -69,14 +77,55 @@ def handle_upload(handler):
except KeyError:
return j(handler, {'error': 'Session not found'}, status=404)
workspace = Path(s.workspace)
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
# Reject names that are purely dots (path traversal: ".." survives regex)
if not safe_name or safe_name.strip('.') == '':
return j(handler, {'error': 'Invalid filename'}, status=400)
# Verify the resolved path stays within the workspace
safe_name = _sanitize_upload_name(filename)
dest = safe_resolve_ws(workspace, safe_name)
dest.write_bytes(file_bytes)
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size})
except Exception as e:
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
except Exception:
print('[webui] upload error: ' + _tb.format_exc(), flush=True)
return j(handler, {'error': 'Upload failed'}, status=500)
def handle_transcribe(handler):
import traceback as _tb
temp_path = None
try:
content_type = handler.headers.get('Content-Type', '')
content_length = int(handler.headers.get('Content-Length', 0) or 0)
if content_length > MAX_UPLOAD_BYTES:
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
fields, files = parse_multipart(handler.rfile, content_type, content_length)
if 'file' not in files:
return j(handler, {'error': 'No file field in request'}, status=400)
filename, file_bytes = files['file']
if not filename:
return j(handler, {'error': 'No filename in upload'}, status=400)
safe_name = _sanitize_upload_name(filename)
suffix = Path(safe_name).suffix or '.webm'
with tempfile.NamedTemporaryFile(prefix='webui-stt-', suffix=suffix, delete=False) as tmp:
temp_path = tmp.name
tmp.write(file_bytes)
try:
from tools.transcription_tools import transcribe_audio
except ImportError:
return j(handler, {'error': 'Speech-to-text is unavailable on this server'}, status=503)
result = transcribe_audio(temp_path)
if not result.get('success'):
msg = str(result.get('error') or 'Transcription failed')
status = 503 if 'unavailable' in msg.lower() or 'not configured' in msg.lower() else 400
return j(handler, {'error': msg}, status=status)
transcript = str(result.get('transcript') or '').strip()
return j(handler, {'ok': True, 'transcript': transcript})
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
except Exception:
print('[webui] transcribe error: ' + _tb.format_exc(), flush=True)
return j(handler, {'error': 'Transcription failed'}, status=500)
finally:
if temp_path:
try:
Path(temp_path).unlink(missing_ok=True)
except Exception:
pass

View File

@@ -8,10 +8,13 @@ profile has its own workspace configuration. State files live at
paths are used as fallback when no profile module is available.
"""
import json
import logging
import os
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
from api.config import (
WORKSPACES_FILE as _GLOBAL_WS_FILE,
LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE,
@@ -37,7 +40,7 @@ def _profile_state_dir() -> Path:
d.mkdir(parents=True, exist_ok=True)
return d
except ImportError:
pass
logger.debug("Failed to import profiles module, using global state dir")
return _GLOBAL_WS_FILE.parent
@@ -80,7 +83,7 @@ def _profile_default_workspace() -> str:
if p.is_dir():
return str(p)
except (ImportError, Exception):
pass
logger.debug("Failed to load profile default workspace config")
return str(_BOOT_DEFAULT_WORKSPACE)
@@ -89,7 +92,6 @@ def _profile_default_workspace() -> str:
def _clean_workspace_list(workspaces: list) -> list:
"""Sanitize a workspace list:
- Remove entries whose paths no longer exist on disk.
- Remove entries that look like test artifacts (webui-mvp-test, test-workspace).
- Remove entries whose paths live inside another profile's directory
(e.g. ~/.hermes/profiles/X/... should not appear on a different profile).
- Rename any entry whose name is literally 'default' to 'Home' (avoids
@@ -102,18 +104,24 @@ def _clean_workspace_list(workspaces: list) -> list:
path = w.get('path', '')
name = w.get('name', '')
p = Path(path).resolve() if path else Path('/')
# Skip test artifacts
if 'test-workspace' in path or 'webui-mvp-test' in path:
continue
# Skip paths that no longer exist
if not p.is_dir():
continue
# Skip paths inside a named profile's directory (cross-profile leak)
# Skip paths inside a DIFFERENT profile's directory (cross-profile leak).
# Allow paths inside the CURRENT profile's own directory (e.g. test workspaces
# created under ~/.hermes/profiles/webui/webui-mvp-test/).
try:
p.relative_to(hermes_profiles)
continue # it IS under profiles/ — remove it
# p is under ~/.hermes/profiles/ — only skip if it's under a DIFFERENT profile
try:
from api.profiles import get_active_hermes_home
own_profile_dir = get_active_hermes_home().resolve()
p.relative_to(own_profile_dir)
# p is under our own profile dir — keep it
except (ValueError, Exception):
continue # under profiles/ but not our own — cross-profile leak, skip
except ValueError:
pass
pass # not under profiles/ at all — keep it
# Rename confusing 'default' label to 'Home'
if name.lower() == 'default':
name = 'Home'
@@ -156,10 +164,10 @@ def load_workspaces() -> list:
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
)
except Exception:
pass
logger.debug("Failed to persist cleaned workspace list")
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
except Exception:
pass
logger.debug("Failed to load workspaces from %s", ws_file)
# No profile-local file yet.
# For the DEFAULT profile: migrate from the legacy global file (one-time cleanup).
# For NAMED profiles: always start clean with just their own workspace.
@@ -190,7 +198,7 @@ def get_last_workspace() -> str:
if p and Path(p).is_dir():
return p
except Exception:
pass
logger.debug("Failed to read last workspace from %s", lw_file)
# Fallback: try global file
if _GLOBAL_LW_FILE.exists():
try:
@@ -198,7 +206,7 @@ def get_last_workspace() -> str:
if p and Path(p).is_dir():
return p
except Exception:
pass
logger.debug("Failed to read global last workspace")
return _profile_default_workspace()
@@ -208,8 +216,249 @@ def set_last_workspace(path: str) -> None:
lw_file.parent.mkdir(parents=True, exist_ok=True)
lw_file.write_text(str(path), encoding='utf-8')
except Exception:
logger.debug("Failed to set last workspace")
def _workspace_blocked_roots() -> tuple[Path, ...]:
return (
# Linux / macOS
Path('/etc'),
Path('/usr'),
Path('/var'),
Path('/bin'),
Path('/sbin'),
Path('/boot'),
Path('/proc'),
Path('/sys'),
Path('/dev'),
Path('/lib'),
Path('/lib64'),
Path('/opt/homebrew'),
)
def _is_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _trusted_workspace_roots() -> list[Path]:
roots: list[Path] = []
def add(candidate: str | Path | None) -> None:
if candidate in (None, ""):
return
try:
p = Path(candidate).expanduser().resolve()
except Exception:
return
if not p.exists() or not p.is_dir():
return
if any(_is_within(p, blocked) for blocked in _workspace_blocked_roots()):
return
if p not in roots:
roots.append(p)
add(Path.home())
add(_BOOT_DEFAULT_WORKSPACE)
for w in load_workspaces():
add(w.get("path"))
roots.sort(key=lambda p: len(str(p)))
return roots
def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
"""Return workspace path suggestions under trusted roots only.
Suggestions are limited to directories under one of:
- Path.home()
- the boot default workspace
- already-saved workspace roots
Arbitrary system prefixes return an empty list rather than an error so the
UI can safely autocomplete while the user types.
"""
roots = _trusted_workspace_roots()
if not roots:
return []
raw = (prefix or "").strip()
if not raw:
return [str(p) for p in roots[:limit]]
if raw.startswith("~"):
target = Path(raw).expanduser()
elif Path(raw).is_absolute():
target = Path(raw)
else:
target = Path.home() / raw
normalized = str(target)
normalized_lower = normalized.lower()
suggestions: list[str] = []
def add(path: Path) -> None:
value = str(path)
if value not in suggestions:
suggestions.append(value)
# If the user is typing a partial trusted root like /Users/xuef..., suggest
# the matching trusted roots without scanning arbitrary system parents.
for root in roots:
if str(root).lower().startswith(normalized_lower):
add(root)
in_root = [
root
for root in roots
if normalized == str(root) or normalized.startswith(str(root) + os.sep)
]
if not in_root:
return suggestions[:limit]
anchor_root = max(in_root, key=lambda p: len(str(p)))
ends_with_sep = raw.endswith(os.sep) or raw.endswith('/')
parent = target if ends_with_sep else target.parent
leaf = '' if ends_with_sep else target.name
show_hidden = leaf.startswith('.')
try:
parent_resolved = parent.expanduser().resolve()
except Exception:
return suggestions[:limit]
if not parent_resolved.exists() or not parent_resolved.is_dir():
return suggestions[:limit]
if not _is_within(parent_resolved, anchor_root):
return suggestions[:limit]
leaf_lower = leaf.lower()
try:
children = sorted(parent_resolved.iterdir(), key=lambda p: p.name.lower())
except OSError:
return suggestions[:limit]
for child in children:
if not child.is_dir():
continue
if child.name.startswith('.') and not show_hidden:
continue
if leaf_lower and not child.name.lower().startswith(leaf_lower):
continue
add(child.resolve())
if len(suggestions) >= limit:
break
return suggestions[:limit]
def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
"""Resolve and validate a workspace path.
A path is trusted if it satisfies at least one of:
(A) It is under the user's home directory (Path.home()).
Works cross-platform: ~/... on Linux/macOS, C:\\Users\\... on Windows.
(B) It is already in the profile's saved workspace list.
This covers self-hosted deployments where workspaces live outside home
(e.g. /data/projects, /opt/workspace) — once a workspace is saved by
an admin, it can be reused without re-validation.
Additionally enforced regardless of (A)/(B):
1. The path must exist.
2. The path must be a directory.
3. The path must not be a known system root (/etc, /usr, /var, /bin, /sbin,
/boot, /proc, /sys, /dev, /root on Linux/macOS; Windows system dirs).
This prevents even admin-saved workspaces from pointing at OS internals.
None/empty path falls back to the boot-time DEFAULT_WORKSPACE, which is always
trusted (it was validated at server startup).
"""
if path in (None, ""):
return Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
candidate = Path(path).expanduser().resolve()
if not candidate.exists():
raise ValueError(f"Path does not exist: {candidate}")
if not candidate.is_dir():
raise ValueError(f"Path is not a directory: {candidate}")
# Block known system roots and their children
for blocked in _workspace_blocked_roots():
try:
candidate.relative_to(blocked)
raise ValueError(f"Path points to a system directory: {candidate}")
except ValueError as e:
if "system directory" in str(e):
raise
# relative_to raised ValueError = candidate is NOT under blocked = safe
# (A) Trusted if under the user's home directory — cross-platform via Path.home()
try:
candidate.relative_to(Path.home().resolve())
return candidate
except ValueError:
pass
# (B) Trusted if already in the saved workspace list — covers non-home installs
try:
saved = load_workspaces()
saved_paths = {Path(w["path"]).resolve() for w in saved if w.get("path")}
if candidate in saved_paths:
return candidate
except Exception:
pass
# (C) Trusted if it is equal to or under the boot-time DEFAULT_WORKSPACE.
# In Docker deployments HERMES_WEBUI_DEFAULT_WORKSPACE is often set to a
# volume mount outside the user's home (e.g. /data/workspace). That path
# was already validated at server startup, so any sub-path of it is safe
# without requiring the user to add it to the workspace list manually.
try:
boot_default = Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
candidate.relative_to(boot_default)
return candidate
except ValueError:
pass
raise ValueError(
f"Path is outside the user home directory, not in the saved workspace "
f"list, and not under the default workspace: {candidate}. "
f"Add it via Settings → Workspaces first."
)
def validate_workspace_to_add(path: str) -> Path:
"""Validate a path for *adding* to the workspace list (less restrictive than resolve_trusted_workspace).
When a user explicitly adds a new workspace path, we trust their intent — they
have console or filesystem access to that path and are consciously registering it.
We only block: non-existent paths, non-directories, and known system roots.
The stricter ``resolve_trusted_workspace`` is used when *using* an existing workspace
(file reads/writes) to prevent path traversal after the list is built.
"""
candidate = Path(path).expanduser().resolve()
if not candidate.exists():
raise ValueError(f"Path does not exist: {candidate}")
if not candidate.is_dir():
raise ValueError(f"Path is not a directory: {candidate}")
# Block known system roots and their immediate children
for blocked in _workspace_blocked_roots():
try:
candidate.relative_to(blocked)
raise ValueError(f"Path points to a system directory: {candidate}")
except ValueError as e:
if "system directory" in str(e):
raise
return candidate
def safe_resolve_ws(root: Path, requested: str) -> Path:
"""Resolve a relative path inside a workspace root, raising ValueError on traversal."""

View File

@@ -19,6 +19,50 @@ from pathlib import Path
INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
REPO_ROOT = Path(__file__).resolve().parent
def _load_repo_dotenv() -> None:
"""Load REPO_ROOT/.env into os.environ.
Mirrors what start.sh does via ``set -a; source .env`` so that running
``python3 bootstrap.py`` directly behaves identically to ``./start.sh``.
Variables are set unconditionally (matching shell source semantics), so a
value in .env overrides one already present in the shell environment.
To keep a CLI-supplied value, unset it from .env or launch via start.sh
and override there.
Only loads the webui repo .env — not ~/.hermes/.env, which the server
loads independently at startup for provider credentials.
Note: does not handle the ``export FOO=bar`` prefix — strip ``export``
from .env values if copy-pasting from a shell rc file.
"""
env_path = REPO_ROOT / ".env"
if not env_path.exists():
return
try:
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
# Strip optional 'export' prefix (common in copy-pasted shell snippets)
if k.startswith("export "):
k = k[7:].strip()
v = v.strip().strip('"').strip("'")
if k:
os.environ[k] = v
except Exception as exc:
import sys as _sys
print(f"[bootstrap] Warning: could not load .env — {exc}", file=_sys.stderr)
# Side effect: loads REPO_ROOT/.env into os.environ on import.
# Must run before DEFAULT_HOST / DEFAULT_PORT so os.getenv() picks up
# values from .env even when bootstrap.py is invoked directly (not via start.sh).
_load_repo_dotenv()
DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
DEFAULT_PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787"))
# Set HERMES_WEBUI_SKIP_ONBOARDING=1 to bypass the first-run wizard when
@@ -69,7 +113,7 @@ def discover_launcher_python(agent_dir: Path | None) -> str:
if env_python:
return env_python
if agent_dir:
for rel in ("venv/bin/python", "venv/Scripts/python.exe"):
for rel in ("venv/bin/python", "venv/Scripts/python.exe", ".venv/bin/python", ".venv/Scripts/python.exe"):
candidate = agent_dir / rel
if candidate.exists():
return str(candidate)
@@ -130,9 +174,12 @@ def install_hermes_agent() -> None:
def wait_for_health(url: str, timeout: float = 25.0) -> bool:
deadline = time.time() + timeout
# Validate URL scheme to prevent file:// and other dangerous schemes
if not url.startswith(("http://", "https://")):
raise ValueError(f"Invalid health check URL: {url}")
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as response:
with urllib.request.urlopen(url, timeout=2) as response: # nosec B310
if b'"status": "ok"' in response.read():
return True
except Exception:

View File

@@ -0,0 +1,121 @@
# Three-container Docker Compose: Hermes Agent + Dashboard + WebUI
#
# This extends the two-container setup with the Hermes Dashboard for
# monitoring agent activity, sessions, and resource usage.
#
# Usage:
# docker compose -f docker-compose.three-container.yml up -d
#
# Services:
# hermes-agent — gateway API on port 8642 (CLI, Telegram, cron, tools)
# hermes-dashboard — monitoring dashboard on port 9119
# hermes-webui — browser chat interface on port 8787
#
# All three share the same hermes-home volume so config, sessions,
# skills, and memory are consistent across all surfaces.
#
# NOTE ON VOLUMES:
# This file uses named Docker volumes (hermes-home, hermes-agent-src) which
# work out of the box. If you prefer bind mounts (e.g. to an existing directory),
# see the two-container compose file for a bind-mount example.
# When using bind mounts, ALL containers must mount the same host path.
services:
hermes-agent:
image: nousresearch/hermes-agent:latest
container_name: hermes-agent
command: gateway run
ports:
- "127.0.0.1:8642:8642"
volumes:
# Persist config, state, sessions, skills, memory across restarts
- hermes-home:/home/hermes/.hermes
# Expose agent source so the WebUI can install dependencies from it
- hermes-agent-src:/opt/hermes
environment:
- HERMES_HOME=/home/hermes/.hermes
- HERMES_UID=${HERMES_UID:-10000}
- HERMES_GID=${HERMES_GID:-10000}
restart: unless-stopped
deploy:
resources:
limits:
memory: 4G
cpus: "2.0"
networks:
- hermes-net
hermes-dashboard:
image: nousresearch/hermes-agent:latest
container_name: hermes-dashboard
command: dashboard --host 0.0.0.0 --insecure
ports:
- "127.0.0.1:9119:9119"
volumes:
- hermes-home:/home/hermes/.hermes
environment:
- HERMES_HOME=/home/hermes/.hermes
- HERMES_UID=${HERMES_UID:-10000}
- HERMES_GID=${HERMES_GID:-10000}
# Dashboard connects to the gateway for health/session data
- GATEWAY_HEALTH_URL=http://hermes-agent:8642
depends_on:
- hermes-agent
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: "0.5"
networks:
- hermes-net
hermes-webui:
image: ghcr.io/nesquena/hermes-webui:latest
container_name: hermes-webui
depends_on:
- hermes-agent
ports:
# Expose on localhost only. Remove 127.0.0.1: to expose on all interfaces
# (set HERMES_WEBUI_PASSWORD if doing so).
- "127.0.0.1:8787:8787"
volumes:
# Same hermes home as the agent — shares config, sessions, state
- hermes-home:/home/hermeswebui/.hermes
# Agent source mounted where docker_init.bash expects it.
# At startup the init script runs:
# uv pip install /home/hermeswebui/.hermes/hermes-agent
# which installs the agent and all its Python dependencies.
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
# Workspace directory — browse and edit files from the WebUI.
# Adapt the host path to your project directory.
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
environment:
- HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
# Match your host user's UID/GID for correct file permissions.
# Run `id -u` and `id -g` to find your values.
# On macOS, UIDs start at 501 (not 1000) — set these in a .env file:
# echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
- WANTED_UID=${UID:-1000}
- WANTED_GID=${GID:-1000}
# NOTE: When using bind-mount volumes shared across containers, ALL containers
# that write to the same host directory must run as the same UID/GID.
# If hermes-agent initialises the state dir as root (UID 0), hermes-webui
# will get a PermissionError accessing those paths — including a crash on every
# HTTP request if the auth signing-key file is unreadable. Either set WANTED_UID
# to match the agent container's UID, or use a named Docker volume (preferred).
# Optional: set a password for remote access
# - HERMES_WEBUI_PASSWORD=your-secret-password
restart: unless-stopped
networks:
- hermes-net
networks:
hermes-net:
driver: bridge
volumes:
hermes-home:
hermes-agent-src:

View File

@@ -10,19 +10,44 @@
# The agent container runs the gateway (CLI, Telegram, cron, etc.).
# The WebUI container serves the browser interface on port 8787.
# Both share ~/.hermes for config, sessions, and state.
#
# NOTE ON VOLUMES:
# This file uses named Docker volumes (hermes-home, hermes-agent-src) which
# work out of the box. If you prefer bind mounts (e.g. to an existing directory),
# replace the named volumes at the bottom. Example for hermes-agent-src:
#
# hermes-agent-src:
# driver: local
# driver_opts:
# type: none
# o: bind
# device: /opt/hermes-agent
#
# When using bind mounts, BOTH containers must mount the same host path.
# The agent exposes source at /opt/hermes, the WebUI reads it from
# /home/hermeswebui/.hermes/hermes-agent — as long as both point to the
# same host directory, the paths align correctly.
services:
hermes-agent:
image: nousresearch/hermes-agent:latest
container_name: hermes-agent
command: gateway run
ports:
# Gateway API — exposed on localhost only.
# Other containers on hermes-net reach it via http://hermes-agent:8642.
# Remove 127.0.0.1: to expose on the host network (e.g. for remote clients).
- "127.0.0.1:8642:8642"
volumes:
# Persist config, state, sessions, skills, memory across restarts
- hermes-home:/root/.hermes
- hermes-home:/home/hermes/.hermes
# Expose agent source so the WebUI can install dependencies from it
- hermes-agent-src:/opt/hermes
environment:
- HERMES_HOME=/root/.hermes
- HERMES_HOME=/home/hermes/.hermes
restart: unless-stopped
networks:
- hermes-net
hermes-webui:
image: ghcr.io/nesquena/hermes-webui:latest
@@ -41,17 +66,29 @@ services:
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
# Workspace directory — browse and edit files from the WebUI.
# Adapt the host path to your project directory.
- ~/workspace:/workspace
# Override with: HERMES_WORKSPACE=/your/path docker compose up
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
environment:
- HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp
# Match your host user's UID/GID for correct file permissions
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
# Match your host user's UID/GID for correct file permissions.
# In two-container setups the WebUI auto-detects UID/GID from the shared
# hermes-home volume, but you can override explicitly if needed (#668):
# Run `id -u` and `id -g` to find your values.
# On macOS, UIDs start at 501 — set these in a .env file:
# echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
- WANTED_UID=${UID:-1000}
- WANTED_GID=${GID:-1000}
# Optional: set a password for remote access
# - HERMES_WEBUI_PASSWORD=your-secret-password
# - HERMES_WEBUI_PASSWORD=***
restart: unless-stopped
networks:
- hermes-net
networks:
hermes-net:
driver: bridge
volumes:
hermes-home:

View File

@@ -8,20 +8,27 @@ services:
- "127.0.0.1:8787:8787"
# - "8787:8787"
volumes:
# Within the containe the tool expects to find the .hermes location at /home/hermeswebui/.hermes, so we mount it there; this allows you to manage agent profiles and other features that rely on the .hermes directory from your host machine, make sure to adapt the path if your HERMES_HOME is different
# Mount hermes home for agent features and profile management
# Mount your Hermes home directory into the container.
# The default (${HOME}/.hermes) works on both macOS (/Users/<you>/.hermes)
# and Linux (/home/<you>/.hermes) — no change needed for standard installs.
# Only set HERMES_HOME explicitly if your .hermes lives somewhere non-standard.
# macOS note: set UID and GID below to match your user ID (run `id -u` and `id -g`).
- ${HERMES_HOME:-${HOME}/.hermes}:/home/hermeswebui/.hermes
# Your workspace directory shown on first launch (adapt if yours is different, the container will use the mounted /workspace)
- ${HERMES_HOME:-${HOME}}/workspace:/workspace
- ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
environment:
# Modify the UID and GID to match your user; docker compose starts as root by default, but the container will drop privileges to the specified UID/GID
# Set to your host user ID: run `id -u` and `id -g` to find them.
# On macOS, UIDs start at 501 (not 1000), so set UID and GID in a .env file:
# echo "UID=$(id -u)" >> .env
# echo "GID=$(id -g)" >> .env
# Without this, the container may not be able to read your mounted files.
- WANTED_UID=${UID:-1000}
- WANTED_GID=${GID:-1000}
# Required: bind address and port
- HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp)
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui)
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
# Default workspace directory shown on first launch
# - HERMES_WEBUI_DEFAULT_WORKSPACE=/workspace
# Optional: set a password for remote access

View File

@@ -59,6 +59,34 @@ it=$itdir/hermeswebui_user_uid
if [ -z "${WANTED_UID+x}" ]; then
if [ -f $it ]; then WANTED_UID=$(cat $it); fi
fi
# Auto-detect from mounted volumes if still unset (#569, #668).
# On macOS, host UIDs start at 501. Using the wrong UID means the container
# user cannot read the bind-mounted files, making the workspace appear empty.
# In two-container setups (hermes-agent + hermes-webui), the shared hermes-home
# volume may be owned by the agent container's UID — detect from there first.
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
# Priority 1: hermes-home shared volume — covers two-container Zeabur/Compose setups (#668)
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
if [ -d "$_probe_dir" ]; then
_detected_uid=$(stat -c '%u' "$_probe_dir" 2>/dev/null || echo "")
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
echo "-- Auto-detected UID: $_detected_uid (from $_probe_dir)"
WANTED_UID=$_detected_uid
break
fi
fi
done
fi
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
# Priority 2: /workspace bind-mount — the standard single-container mount point
if [ -d "/workspace" ]; then
_detected_uid=$(stat -c '%u' "/workspace" 2>/dev/null || echo "")
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
echo "-- Auto-detected workspace UID: $_detected_uid (from /workspace)"
WANTED_UID=$_detected_uid
fi
fi
fi
WANTED_UID=${WANTED_UID:-1024}
write_worldtmpfile $it "$WANTED_UID"
echo "-- WANTED_UID: \"${WANTED_UID}\""
@@ -67,6 +95,30 @@ it=$itdir/hermeswebui_user_gid
if [ -z "${WANTED_GID+x}" ]; then
if [ -f $it ]; then WANTED_GID=$(cat $it); fi
fi
# Auto-detect GID from mounted volumes to match (#569, #668)
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
# Priority 1: hermes-home shared volume
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
if [ -d "$_probe_dir" ]; then
_detected_gid=$(stat -c '%g' "$_probe_dir" 2>/dev/null || echo "")
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
echo "-- Auto-detected GID: $_detected_gid (from $_probe_dir)"
WANTED_GID=$_detected_gid
break
fi
fi
done
fi
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
# Priority 2: /workspace bind-mount
if [ -d "/workspace" ]; then
_detected_gid=$(stat -c '%g' "/workspace" 2>/dev/null || echo "")
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
echo "-- Auto-detected workspace GID: $_detected_gid (from /workspace)"
WANTED_GID=$_detected_gid
fi
fi
fi
WANTED_GID=${WANTED_GID:-1024}
write_worldtmpfile $it "$WANTED_GID"
echo "-- WANTED_GID: \"${WANTED_GID}\""
@@ -187,16 +239,31 @@ rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_STATE_DIR"
echo ""; echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: Default workspace directory shown on first launch"
if [ -z "${HERMES_WEBUI_DEFAULT_WORKSPACE+x}" ]; then echo "HERMES_WEBUI_DEFAULT_WORKSPACE not set, setting to /workspace"; export HERMES_WEBUI_DEFAULT_WORKSPACE="/workspace"; fi;
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: $HERMES_WEBUI_DEFAULT_WORKSPACE"
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then mkdir -p $HERMES_WEBUI_DEFAULT_WORKSPACE || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi
# Use sudo for mkdir — Docker may auto-create bind-mount directories as root (#357).
# Skip mkdir if the directory already exists (e.g. a read-only mount — #670).
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then
sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
fi
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi
it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it || error_exit "Failed to verify default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_DEFAULT_WORKSPACE"
# Only chown and write-test if the workspace is writable. Read-only bind-mounts
# (:ro) are valid — the workspace is used for browsing, not writing by the server.
if [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then
sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || echo "!! WARNING: Could not chown $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)"
it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it && rm -f $it || echo "!! WARNING: Could not write to $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)"
else
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE is read-only — skipping chown/write check (read-only workspace is supported)"
fi
echo ""; echo "==================="
echo ""; echo "== Installing uv and creating a new virtual environment for hermes-webui"
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="/home/hermeswebui/.local/bin/:$PATH"
if command -v uv &>/dev/null; then
echo "-- uv already installed ($(uv --version)), skipping download"
else
echo "-- uv not found, downloading..."
curl -LsSf https://astral.sh/uv/install.sh | sh || error_exit "Failed to install uv — check network connectivity"
fi
export UV_PROJECT_ENVIRONMENT=venv
export UV_CACHE_DIR=/uv_cache
@@ -227,7 +294,32 @@ else
test -x /app/venv/bin/pip
echo ""; echo "== Adding hermes-agent's pyproject.toml base dependencies to the virtual environment"
uv pip install /home/hermeswebui/.hermes/hermes-agent --trusted-host pypi.org --trusted-host files.pythonhosted.org || error_exit "Failed to install hermes-agent's requirements"
_agent_paths=(
"/home/hermeswebui/.hermes/hermes-agent"
"/opt/hermes"
)
_agent_src=""
for _p in "${_agent_paths[@]}"; do
if [ -d "$_p" ] && [ -f "$_p/pyproject.toml" ]; then
_agent_src="$_p"
break
fi
done
if [ -n "$_agent_src" ]; then
uv pip install "$_agent_src[all]" --trusted-host pypi.org --trusted-host files.pythonhosted.org || error_exit "Failed to install hermes-agent's requirements"
else
echo ""
echo "!! WARNING: hermes-agent source not found."
echo "!! Looked in: ${_agent_paths[0]}"
echo "!! ${_agent_paths[1]}"
echo "!! The WebUI will start with reduced functionality (no model auto-detection,"
echo "!! no personality routing, no CLI session imports)."
echo "!! To fix: mount the agent source volume into the container:"
echo "!! -v /path/to/hermes-agent:/home/hermeswebui/.hermes/hermes-agent"
echo "!! Or see the two-container compose example:"
echo "!! https://github.com/nesquena/hermes-webui/blob/master/docker-compose.two-container.yml"
echo ""
fi
touch /app/venv/.deps_installed
fi

838
docs/ui-ux/index.html Normal file
View File

@@ -0,0 +1,838 @@
<!doctype html>
<html lang="en" data-theme="slate">
<head>
<meta charset="utf-8">
<title>Hermes WebUI — Messages UI Inventory</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<!-- Real app stylesheet -->
<link rel="stylesheet" href="../../static/style.css">
<!-- Prism (same theme the app pulls at runtime) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
<!-- KaTeX -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<style>
/* Showcase scaffold — styles only for the doc chrome. Everything inside
.messages uses the real app CSS unchanged. */
/* Real app CSS makes <body> a fixed-height flex shell. Undo that so this
doc page can scroll normally with a stacked header + main. */
body{display:block !important;height:auto !important;min-height:100vh;overflow:auto !important;}
.doc-main{display:block;}
.doc-header{position:sticky;top:0;z-index:50;background:var(--topbar-bg);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;flex-wrap:wrap;align-items:center;gap:14px;}
.doc-title{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--text);}
.doc-title small{display:block;font-size:11px;font-weight:500;color:var(--muted);margin-top:3px;}
.doc-toggles{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto;}
.doc-toggles button{font:inherit;font-size:11px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);cursor:pointer;}
.doc-toggles button.on{background:rgba(124,185,255,.12);border-color:rgba(124,185,255,.4);color:var(--blue);}
.doc-main{max-width:1100px;margin:0 auto;padding:24px 24px 120px;}
.doc-section{margin:40px 0 8px;padding-top:20px;border-top:1px dashed var(--border);}
.doc-section:first-of-type{border-top:none;padding-top:0;margin-top:0;}
.doc-kicker{font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--blue);}
.doc-h{font-size:18px;font-weight:700;color:var(--text);margin:4px 0 4px;}
.doc-note{font-size:12px;color:var(--muted);line-height:1.55;max-width:760px;margin-bottom:10px;}
.doc-card{position:relative;background:var(--main-bg);border:1px solid var(--border);border-radius:12px;padding:4px 6px;margin:12px 0;}
.doc-label{position:absolute;top:-9px;left:12px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;padding:2px 8px;background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:999px;}
/* Force-show hover-only affordances inside explicitly flagged demos */
.force-show .msg-actions,
.force-show .msg-time,
.force-show .msg-foot{opacity:1 !important;}
/* Chat demo container mimics the app's .messages scroll wrapper but not fullscreen */
.messages.doc-messages{overflow:visible;display:block;}
.messages-inner.doc-inner{padding:14px 16px;}
/* Make the in-page demos of approval/clarify cards visible without JS */
.approval-card.doc-visible,
.clarify-card.doc-visible{display:block;}
.reconnect-banner.doc-visible{display:flex;align-items:center;justify-content:space-between;gap:12px;background:rgba(201,168,76,.12);border:1px solid rgba(201,168,76,.3);color:var(--gold);padding:8px 14px;border-radius:8px;font-size:12px;}
.reconnect-banner.doc-visible .reconnect-btn{background:none;border:1px solid rgba(201,168,76,.35);color:var(--gold);padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;}
.bg-error-banner.doc-visible{border-radius:8px;}
/* Two-up grid for short comparisons */
.doc-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;}
</style>
</head>
<body>
<header class="doc-header">
<div class="doc-title">Hermes WebUI — Messages UI Inventory<small>Every message-area element &amp; combination, wired to the real <code>static/style.css</code>. &nbsp;·&nbsp; <a href="./two-stage-proposal.html" style="color:var(--blue);text-decoration:none;">Two-stage proposal (#536) →</a></small></div>
<div class="doc-toggles">
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
<button data-theme-btn="default">Default</button>
<button data-theme-btn="slate" class="on">Slate</button>
<button data-theme-btn="light">Light</button>
<button data-theme-btn="solarized">Solarized</button>
<button data-theme-btn="monokai">Monokai</button>
<button data-theme-btn="nord">Nord</button>
<button data-theme-btn="oled">OLED</button>
<span style="width:1px;height:18px;background:var(--border);margin:0 4px;align-self:center;"></span>
</div>
</header>
<main class="doc-main">
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">1 · Empty state</div>
<h2 class="doc-h">First load / no messages</h2>
<p class="doc-note">Renders inside <code>#messages</code> when <code>S.messages</code> is empty. Logo + title + subtitle + 3 suggestion buttons.</p>
<div class="doc-card"><span class="doc-label">.empty-state</span>
<div class="messages doc-messages">
<div class="empty-state" style="min-height:340px;flex:0 0 auto;">
<div class="empty-logo">H</div>
<h2>What can I help with?</h2>
<p>Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
<div class="suggestion-grid">
<button class="suggestion">📁 What files are in this workspace?</button>
<button class="suggestion">📅 What's on my schedule today?</button>
<button class="suggestion">🗺️ Help me plan a small project.</button>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">2 · User messages</div>
<h2 class="doc-h">Right-aligned bubble, attachments, and edit mode</h2>
<p class="doc-note">User rows have no avatar/label — the right-edge alignment and tinted bubble identify the sender. Timestamp + edit/copy live in a <code>.msg-foot</code> below the bubble, revealed on hover (forced visible here).</p>
<div class="doc-card"><span class="doc-label">.msg-row[data-role="user"] — plain</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row force-show" data-role="user" data-raw-text="How do I run the dev server and point it at a specific workspace path?">
<div class="msg-body"><p>How do I run the dev server and point it at a specific workspace path?</p></div>
<div class="msg-foot">
<span class="msg-time" title="Thu, Apr 16 2026, 10:42 AM">10:42</span>
<span class="msg-actions">
<button class="msg-action-btn" title="Edit"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
</span>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-files — attachments above body (right-aligned)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user">
<div class="msg-files">
<span class="msg-file-badge">📎 architecture-notes.pdf</span>
<span class="msg-file-badge">📎 Q1-forecast.xlsx</span>
<span class="msg-file-badge">📎 meeting.docx</span>
<span class="msg-file-badge">📎 screenshot.png</span>
</div>
<div class="msg-body"><p>Please review these docs and summarise the key decisions.</p></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-edit-area + .msg-edit-bar — edit mode</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user" data-editing="1">
<textarea class="msg-edit-area">How do I run the dev server and point it at a specific workspace path — and can I do it without docker?</textarea>
<div class="msg-edit-bar">
<button class="msg-edit-send">Send edit</button>
<button class="msg-edit-cancel">Cancel</button>
</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">3 · Assistant — markdown basics</div>
<h2 class="doc-h">Paragraphs, emphasis, lists, blockquote, hr, links</h2>
<p class="doc-note">Assistant output is a single <code>.msg-row.assistant-turn</code> that holds one role header + an <code>.assistant-turn-blocks</code> column of one-or-more <code>.assistant-segment</code> children. Each segment may contain a <code>.thinking-card</code>, a <code>.msg-body</code>, and its own <code>.msg-foot</code> (copy / regen). This lets a turn stream reasoning → text → tool calls → more text without repeating the Hermes avatar each time.</p>
<div class="doc-card"><span class="doc-label">.msg-body — rich prose</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn force-show" data-role="assistant">
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
<span class="role-icon assistant">H</span>
<span>Hermes</span>
</div>
<div class="assistant-turn-blocks">
<div class="assistant-segment" data-raw-text="Running the dev server...">
<div class="msg-body">
<h1>Running the dev server</h1>
<p>You can start Hermes with the built-in launcher. The <strong>simplest path</strong> is <em>no docker, no proxy</em> — the CLI handles everything.</p>
<h2>Prerequisites</h2>
<ul>
<li>Node <code>&gt;= 18</code></li>
<li>A workspace directory you own
<ul>
<li>Read/write permissions</li>
<li>No existing <code>.hermes</code> folder</li>
</ul>
</li>
<li>An API key set via <code>HERMES_API_KEY</code></li>
</ul>
<h2>Steps</h2>
<ol>
<li>Clone the repo</li>
<li>Run <code>npm install</code></li>
<li>Start with <code>npm run dev -- --workspace ~/code</code></li>
</ol>
<blockquote>Tip: the <code>--workspace</code> flag accepts absolute or <code>~</code>-prefixed paths. Relative paths are resolved against the CWD.</blockquote>
<hr>
<p>For full setup options see the <a href="#">configuration guide</a>.</p>
</div>
<div class="msg-foot">
<span class="msg-actions">
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
<button class="msg-action-btn" title="Regenerate"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
</span>
</div>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-body table</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks">
<div class="assistant-segment">
<div class="msg-body">
<p>Model comparison:</p>
<table>
<thead><tr><th>Model</th><th>Context</th><th>Good for</th><th>Cost / 1M in</th></tr></thead>
<tbody>
<tr><td>Opus 4.6</td><td>1M</td><td>Deep reasoning, long code</td><td><code>$15.00</code></td></tr>
<tr><td>Sonnet 4.6</td><td>1M</td><td>Daily driver, agents</td><td><code>$3.00</code></td></tr>
<tr><td>Haiku 4.5</td><td>200k</td><td>Fast tasks, tool loops</td><td><code>$0.80</code></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">4 · Code blocks</div>
<h2 class="doc-h">Plain, with header, with copy button, multi-language</h2>
<div class="doc-card"><span class="doc-label">pre + code (no header)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<pre><code class="language-bash">npm install
npm run dev -- --workspace ~/code</code></pre>
</div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.pre-header + pre + .code-copy-btn</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<div style="position:relative;">
<div class="pre-header">typescript <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
<pre><code class="language-typescript">export async function startServer(opts: ServerOptions) {
const port = opts.port ?? 3000;
const app = createApp();
app.listen(port, () =&gt; {
console.log(`Hermes listening on :${port}`);
});
return app;
}</code></pre>
</div>
<div style="position:relative;margin-top:14px;">
<div class="pre-header">python <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
<pre><code class="language-python">from hermes import Agent
def main() -&gt; None:
agent = Agent(model="claude-opus-4-6")
reply = agent.run("Summarise today's commits")
print(reply)
if __name__ == "__main__":
main()</code></pre>
</div>
<div style="position:relative;margin-top:14px;">
<div class="pre-header">json <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
<pre><code class="language-json">{
"model": "claude-sonnet-4-6",
"stream": true,
"tools": ["bash", "edit_file", "search"]
}</code></pre>
</div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">5 · Inline media</div>
<h2 class="doc-h">Images (default &amp; zoomed) and downloadable links</h2>
<div class="doc-card"><span class="doc-label">.msg-media-img (default + .msg-media-img--full)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>Here's the screenshot you asked for (click to zoom):</p>
<img class="msg-media-img" alt="demo" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='360'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' x2='1'%3E%3Cstop offset='0' stop-color='%237cb9ff'/%3E%3Cstop offset='1' stop-color='%23c9a84c'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23g)' width='640' height='360'/%3E%3Ctext x='50%25' y='50%25' font-family='system-ui' font-size='28' fill='white' text-anchor='middle' dominant-baseline='middle'%3E.msg-media-img (480×400 cap)%3C/text%3E%3C/svg%3E">
<p style="margin-top:10px;">And the full-width variant:</p>
<img class="msg-media-img msg-media-img--full" alt="demo-full" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1280' height='320'%3E%3Crect fill='%231e2023' width='1280' height='320'/%3E%3Ctext x='50%25' y='50%25' font-family='system-ui' font-size='28' fill='%2382aaff' text-anchor='middle' dominant-baseline='middle'%3E.msg-media-img--full (unbounded)%3C/text%3E%3C/svg%3E">
</div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-media-link — non-image downloads</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>I saved the generated files:</p>
<p><a class="msg-media-link" href="#">📎 report-2026-Q1.pdf</a> <a class="msg-media-link" href="#">📎 revenue.csv</a> <a class="msg-media-link" href="#">📎 diagram.svg</a></p>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">6 · Math &amp; diagrams</div>
<h2 class="doc-h">KaTeX inline / block &amp; Mermaid block</h2>
<div class="doc-card"><span class="doc-label">.katex-inline + .katex-block</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>Inline math: <span class="katex-inline" data-math-inline>\(E = mc^2\)</span> and the quadratic formula below:</p>
<div class="katex-block" data-math-block>$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$</div>
<p>A tidier form: <span class="katex-inline" data-math-inline>\(\sum_{i=1}^{n} i = \frac{n(n+1)}{2}\)</span>.</p>
<div class="katex-block" data-math-block>$$\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}$$</div>
</div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.mermaid-block (pre-render placeholder)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>The request flow:</p>
<div class="mermaid-block"><pre style="margin:0;background:none;border:none;padding:0;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:12px;">graph LR
U[User] --&gt; C[Composer]
C --&gt; API[/api/chat/]
API --&gt; M((Model))
M --&gt; T{tool?}
T -- yes --&gt; X[Tool Runner]
T -- no --&gt; R[Reply]
X --&gt; R
R --&gt; U</pre></div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">7 · Thinking / reasoning</div>
<h2 class="doc-h">Bordered panel (collapsed / open, animated), live loader, streaming cursor</h2>
<p class="doc-note">Thinking cards are rendered at the top of an <code>.assistant-segment</code>. They're now bordered gold-tinted panels (no more left-rule-only look) and expand/collapse with a <code>max-height</code> + opacity transition. Click the header in either example below to see the animation live.</p>
<div class="doc-grid">
<div class="doc-card"><span class="doc-label">.thinking-card (collapsed, inside .assistant-segment)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner" style="padding-top:8px;">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="thinking-card">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 4.3s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>The user asked about the dev server...</pre></div>
</div>
<div class="msg-body"><p>Here's the shortest path…</p></div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.thinking-card.open (animated — max-height + opacity)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner" style="padding-top:8px;">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 4.3s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>The user is asking about launching the dev server.
Options: npm script, docker, or the bundled CLI.
The CLI is the simplest — no container runtime needed.
I should show the exact commands and the --workspace flag,
then mention the env var for the API key at the end.</pre></div>
</div>
<div class="msg-body"><p>Here's the shortest path…</p></div>
</div></div>
</div>
</div></div>
</div>
</div>
<div class="doc-card"><span class="doc-label">.thinking — live 3-dot loader (pre-reasoning)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="thinking">Thinking <span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">[data-live-assistant="1"] — streaming cursor at end of last child</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant" id="liveAssistantTurn">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="msg-body"><p>Sure — the simplest way is to run <code>npm run dev</code>. The CLI will pick up the default</p></div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">8 · Tool cards</div>
<h2 class="doc-h">Running, done, expanded, subagent, error, multi-card toggle</h2>
<p class="doc-note">Tool cards sit in <code>.tool-card-row</code> wrappers (no longer nested under <code>.msg-row</code>). The details panel now animates open/closed via <code>max-height</code> + opacity — click any header below to see the transition.</p>
<div class="doc-card"><span class="doc-label">.tool-card.tool-card-running (collapsed, pulsing dot)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card tool-card-running">
<div class="tool-card-header">
<span class="tool-card-running-dot"></span>
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">npm run build</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card — done, collapsed</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">static/style.css · 1155 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card.open — args table + result snippet + Show more (animated detail)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card open">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "msg-role" static/ · exit 0 · 380ms</span>
<span class="tool-card-toggle"></span>
</div>
<div class="tool-card-detail">
<div class="tool-card-args">
<div><span class="tool-arg-key">command:</span> <span class="tool-arg-val">grep -rn "msg-role" static/</span></div>
<div><span class="tool-arg-key">cwd:</span> <span class="tool-arg-val">/Users/aron/hermes-webui</span></div>
<div><span class="tool-arg-key">timeout:</span> <span class="tool-arg-val">30000</span></div>
</div>
<div class="tool-card-result">
<pre>static/style.css:430: .msg-role{font-size:12px;font-weight:500...}
static/style.css:431: .msg-role.user{color:rgba(124,185,255,0.65);}
static/style.css:432: .msg-role.assistant{color:rgba(201,168,76,0.6);}
static/ui.js:1141: const roleEl = el('div', 'msg-role ' + role);</pre>
<button class="tool-card-more">Show more (+142 lines)</button>
</div>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card.tool-card-subagent — delegated work</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card tool-card-subagent">
<div class="tool-card-header">
<span class="tool-card-icon">🤖</span>
<span class="tool-card-name">Subagent</span>
<span class="tool-card-preview">Explore · Map chat messages UI elements</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card tool-card-subagent">
<div class="tool-card-header">
<span class="tool-card-icon">🤖</span>
<span class="tool-card-name">Delegate task</span>
<span class="tool-card-preview">Plan · Propose redesign variants</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card (error snippet)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card open">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">npm run typecheck · exit 1 · 2.3s</span>
<span class="tool-card-toggle"></span>
</div>
<div class="tool-card-detail">
<div class="tool-card-args">
<div><span class="tool-arg-key">command:</span> <span class="tool-arg-val">npm run typecheck</span></div>
</div>
<div class="tool-card-result">
<pre style="color:#fca5a5;">src/server.ts:42:7 - error TS2345: Argument of type 'string | undefined'
is not assignable to parameter of type 'number'.
42 app.listen(opts.port, () =&gt; {
~~~~~~~~~</pre>
</div>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-cards-toggle — Expand/Collapse All (≥2 cards)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-cards-toggle">
<button>Expand all (3)</button>
<button>Collapse all</button>
</div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">package.json</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">🔎</span><span class="tool-card-name">grep</span><span class="tool-card-preview">"listen" in src/</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">npm run typecheck · exit 0 · 4.1s</span><span class="tool-card-toggle"></span></div></div></div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">9 · Meta affordances</div>
<h2 class="doc-h">Role timestamp tooltip, footer action toolbar, token-usage badge</h2>
<p class="doc-note">Assistant timestamps live on the <code>.msg-role</code> <code>title</code> attribute (hover for full date). Copy/regen buttons sit in the per-segment <code>.msg-foot</code>, 45% opacity at rest, full on turn hover. The <code>.msg-usage</code> badge is always visible at the bottom of the turn.</p>
<div class="doc-card"><span class="doc-label">Full hover state — .msg-foot actions + .msg-usage</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn force-show" data-role="assistant">
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
<span class="role-icon assistant">H</span>
<span>Hermes</span>
</div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body"><p>Built and type-checked successfully — server is running on <code>:3000</code>.</p></div>
<div class="msg-foot">
<span class="msg-actions">
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
<button class="msg-action-btn" title="Regenerate"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
</span>
</div>
</div></div>
<div class="msg-usage">3.2K in · 481 out · ~$0.012</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">10 · Full composition</div>
<h2 class="doc-h">User turn → assistant turn (segment 1: thinking + body + tool cards) → usage</h2>
<p class="doc-note">A realistic turn: one role header up top, then the segment hosting a thinking card plus the first body; tool cards follow as siblings of the turn inside <code>.messages-inner</code>; the usage badge closes the turn.</p>
<div class="doc-card"><span class="doc-label">All-in-one turn</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row force-show" data-role="user">
<div class="msg-files"><span class="msg-file-badge">📎 server.ts</span></div>
<div class="msg-body"><p>The build fails — can you type-check and explain?</p></div>
<div class="msg-foot">
<span class="msg-time">10:40</span>
<span class="msg-actions">
<button class="msg-action-btn" title="Edit"></button>
<button class="msg-copy-btn msg-action-btn" title="Copy"></button>
</span>
</div>
</div>
<div class="msg-row assistant-turn force-show" data-role="assistant">
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
<span class="role-icon assistant">H</span><span>Hermes</span>
</div>
<div class="assistant-turn-blocks">
<div class="assistant-segment">
<div class="thinking-card open">
<div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 2.1s</span><span class="thinking-card-toggle"></span></div>
<div class="thinking-card-body"><pre>Attached server.ts — probably typing issue.
Run typecheck to confirm, then patch.</pre></div>
</div>
<div class="msg-body">
<p>The build fails because <code>opts.port</code> can be <code>undefined</code>. Two fixes below — pick the one that matches your intent.</p>
<h3>Option A — require the port</h3>
<pre><code class="language-typescript">export function startServer(opts: { port: number }) {
app.listen(opts.port);
}</code></pre>
<h3>Option B — default to 3000</h3>
<pre><code class="language-typescript">export function startServer(opts: { port?: number } = {}) {
const port = opts.port ?? 3000;
app.listen(port);
}</code></pre>
<p>I ran the checks below to confirm.</p>
</div>
<div class="msg-foot">
<span class="msg-actions">
<button class="msg-copy-btn msg-action-btn" title="Copy"></button>
<button class="msg-action-btn" title="Regenerate"></button>
</span>
</div>
</div>
</div>
<div class="msg-usage">11.4K in · 612 out · ~$0.049</div>
</div>
<div class="tool-cards-toggle">
<button>Expand all (3)</button><button>Collapse all</button>
</div>
<div class="tool-card-row"><div class="tool-card open">
<div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">src/server.ts · 58 lines</span><span class="tool-card-toggle"></span></div>
<div class="tool-card-detail">
<div class="tool-card-args"><div><span class="tool-arg-key">path:</span> <span class="tool-arg-val">src/server.ts</span></div></div>
<div class="tool-card-result"><pre>export function startServer(opts: ServerOptions) {
app.listen(opts.port, () =&gt; { ... });
}</pre></div>
</div>
</div></div>
<div class="tool-card-row"><div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">npm run typecheck · exit 1 · 2.3s</span><span class="tool-card-toggle"></span></div>
</div></div>
<div class="tool-card-row"><div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">✏️</span><span class="tool-card-name">edit_file</span><span class="tool-card-preview">src/server.ts +1 / -1</span><span class="tool-card-toggle"></span></div>
</div></div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">12 · System / inline notes</div>
<h2 class="doc-h">Compression, cancellation, errors — rendered as italicised assistant messages</h2>
<div class="doc-card"><span class="doc-label">Italic system notices (still italic — info, not errors)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks">
<div class="assistant-segment"><div class="msg-body"><p><em>[Context was auto-compressed to continue the conversation]</em></p></div></div>
<div class="assistant-segment"><div class="msg-body"><p><em>Task cancelled.</em></p></div></div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.assistant-segment[data-error="1"] — real error card, red accent, no italic</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks">
<div class="assistant-segment" data-error="1"><div class="msg-body"><p><strong>Error:</strong> Connection lost. Your last message was saved — refresh to continue.</p></div></div>
<div class="assistant-segment" data-error="1"><div class="msg-body"><p><strong>Error:</strong> Upstream rate-limited (429). Retrying in 30s…</p></div></div>
</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">12b · Turn boundaries &amp; date separators</div>
<h2 class="doc-h">Right-alignment separates user turns · day-change separator</h2>
<p class="doc-note">The dashed divider before each user turn was removed — the right-edge bubble alignment is its own visual break, so only a small vertical gap (10px top margin) remains between turns. Day changes still get a centred <code>.msg-date-sep</code>.</p>
<div class="doc-card"><span class="doc-label">.msg-date-sep — Today / Yesterday / weekday / date</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-date-sep">Yesterday</div>
<div class="msg-row" data-role="user"><div class="msg-body"><p>Can you summarise the PR I opened earlier?</p></div></div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment"><div class="msg-body"><p>Yes — three files changed, net +42 / -18. Main change is the new rail variable…</p></div></div></div>
</div>
<div class="msg-date-sep">Today</div>
<div class="msg-row" data-role="user"><div class="msg-body"><p>Did CI pass overnight?</p></div></div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment"><div class="msg-body"><p>All green — three jobs, 4m 12s total. Here's the breakdown:</p></div></div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">13 · Overlay cards (adjacent to transcript)</div>
<h2 class="doc-h">Approval &amp; Clarify cards + reconnect banner</h2>
<div class="doc-card"><span class="doc-label">.approval-card — 4 button variants (once / session / always / deny)</span>
<div class="approval-card doc-visible">
<div class="approval-inner">
<div class="approval-header">⚠ Approval required</div>
<div class="approval-desc" style="font-size:12px;color:var(--muted);margin-bottom:8px;">The agent wants to run a shell command in <code>/Users/aron/hermes-webui</code>.</div>
<div class="approval-cmd">rm -rf node_modules &amp;&amp; npm install</div>
<div class="approval-btns">
<button class="approval-btn once"><span class="approval-btn-label">Allow once</span><kbd class="approval-kbd"></kbd></button>
<button class="approval-btn session">🔒 <span class="approval-btn-label">Allow session</span></button>
<button class="approval-btn always"><span class="approval-btn-label">Always allow</span></button>
<button class="approval-btn deny"><span class="approval-btn-label">Deny</span></button>
</div>
</div>
</div>
</div>
<div class="doc-card"><span class="doc-label">.clarify-card — choice buttons + free-text fallback</span>
<div class="clarify-card doc-visible">
<div class="clarify-inner">
<div class="clarify-header">? Clarification needed</div>
<div class="clarify-question">Which environment should I deploy this to?</div>
<div class="clarify-choices">
<button class="clarify-choice"><span class="clarify-choice-badge">A</span><span class="clarify-choice-text">Staging — safe sandbox, auto-teardown nightly</span></button>
<button class="clarify-choice"><span class="clarify-choice-badge">B</span><span class="clarify-choice-text">Production EU — customer-facing, requires change ticket</span></button>
<button class="clarify-choice"><span class="clarify-choice-badge">C</span><span class="clarify-choice-text">Production US — same caveats as EU</span></button>
<button class="clarify-choice other"><span class="clarify-choice-badge other"></span><span class="clarify-choice-text">Other — I'll type it below</span></button>
</div>
<div class="clarify-response">
<input class="clarify-input" type="text" placeholder="Type your response…">
<button class="clarify-submit">Send</button>
</div>
<div class="clarify-hint">Pick a choice, or type your own answer below.</div>
</div>
</div>
</div>
<div class="doc-card"><span class="doc-label">Reconnect / mid-stream recovery banner</span>
<div class="reconnect-banner doc-visible">
<span>⚠ A response may have been in progress when you last left. Reload messages?</span>
<div style="display:flex;gap:8px;">
<button class="reconnect-btn">Dismiss</button>
<button class="reconnect-btn">↻ Reload</button>
</div>
</div>
<div class="bg-error-banner doc-visible" style="margin-top:8px;">
<span>⚠ Agent run exited with non-zero status (code 1). Check the logs.</span>
<button class="reconnect-btn">Dismiss</button>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">14 · Structure &amp; data-attribute cheat sheet</div>
<h2 class="doc-h">Wrappers and state markers produced by <code>renderMessages()</code></h2>
<div class="doc-card" style="padding:14px 18px;">
<h3 style="font-size:13px;color:var(--text);margin:0 0 8px;">Wrappers</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>.msg-row[data-role="user"]</code> — one user turn (right-aligned bubble, 60% max-width)</li>
<li><code>.msg-row.assistant-turn[data-role="assistant"]</code> — one assistant turn; contains <strong>one</strong> <code>.msg-role</code> and <strong>one</strong> <code>.assistant-turn-blocks</code></li>
<li><code>.assistant-turn-blocks</code> — flex-column holder for segments</li>
<li><code>.assistant-segment</code> — a single logical chunk inside a turn: optional <code>.thinking-card</code> + optional <code>.msg-body</code> + optional <code>.msg-foot</code></li>
<li><code>.assistant-segment-anchor</code> — hidden segment kept as a DOM anchor for tool cards when the model emitted no text</li>
<li><code>.tool-card-row</code> — per-tool-card wrapper, sibling of the turn inside <code>.messages-inner</code></li>
<li><code>.msg-foot</code> — per-segment (or per-user-row) footer holding <code>.msg-time</code> + <code>.msg-actions</code></li>
</ul>
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Data attributes &amp; IDs</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>data-role="user|assistant"</code> — role marker on the row</li>
<li><code>data-msgIdx="N"</code> — index into <code>S.messages</code>; on user rows <em>and</em> assistant segments</li>
<li><code>data-raw-text="…"</code> — plain-text source for copy (now lives on <code>.assistant-segment</code> for assistant output)</li>
<li><code>data-live-assistant="1"</code> — the segment that's currently streaming</li>
<li><code>data-editing="1"</code> — row is in edit mode</li>
<li><code>data-error="1"</code> — error state; applies to <code>.msg-row</code> (user) or <code>.assistant-segment</code></li>
<li><code>id="liveAssistantTurn"</code> — on the turn that contains the streaming segment</li>
<li><code>.tool-card-row[data-live-tid="…"]</code> — live tool-call card (removed when the turn settles)</li>
<li><code>data-mermaid-id</code>, <code>data-katex</code>, <code>data-rendered</code> — block rendering state</li>
</ul>
</div>
</section>
</main>
<!-- ============================================================= -->
<!-- Prism autoloader for real syntax highlighting -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<!-- KaTeX auto-render -->
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false},{left:'$',right:'$',display:false}],throwOnError:false});"></script>
<script>
// Theme picker
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.themeBtn;
if (t === 'default') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', t);
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
});
});
// Bubble-layout toggle
// Thinking / tool-card click-to-toggle (so the demo feels live)
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
h.addEventListener('click', () => h.parentElement.classList.toggle('open'));
});
</script>
</body>
</html>

View File

@@ -0,0 +1,742 @@
<!doctype html>
<html lang="en" data-theme="slate">
<head>
<meta charset="utf-8">
<title>Hermes WebUI — Two-Stage Chat Proposal (Issue #536)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="../../static/style.css">
<style>
/* ──────────────────────────────────────────────────────────────
Doc-chrome scaffold (same pattern as index.html) — real app CSS
is used unchanged inside .messages / .msg-row. New proposed
elements are prefixed .p2s- so nothing collides with the app.
────────────────────────────────────────────────────────────── */
body{display:block !important;height:auto !important;min-height:100vh;overflow:auto !important;}
.doc-header{position:sticky;top:0;z-index:50;background:var(--topbar-bg);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;flex-wrap:wrap;align-items:center;gap:14px;}
.doc-title{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--text);}
.doc-title small{display:block;font-size:11px;font-weight:500;color:var(--muted);margin-top:3px;}
.doc-title a{color:var(--blue);text-decoration:none;}
.doc-toggles{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto;}
.doc-toggles button{font:inherit;font-size:11px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);cursor:pointer;}
.doc-toggles button.on{background:rgba(124,185,255,.12);border-color:rgba(124,185,255,.4);color:var(--blue);}
.doc-main{max-width:1180px;margin:0 auto;padding:24px 24px 120px;}
.doc-section{margin:48px 0 8px;padding-top:22px;border-top:1px dashed var(--border);}
.doc-section:first-of-type{border-top:none;padding-top:0;margin-top:0;}
.doc-kicker{font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--blue);}
.doc-h{font-size:20px;font-weight:700;color:var(--text);margin:4px 0 6px;letter-spacing:-.01em;}
.doc-note{font-size:12.5px;color:var(--muted);line-height:1.6;max-width:780px;margin-bottom:14px;}
.doc-note code{color:var(--text);background:rgba(255,255,255,.05);padding:1px 5px;border-radius:4px;font-size:11.5px;}
.doc-card{position:relative;background:var(--main-bg);border:1px solid var(--border);border-radius:14px;padding:6px 8px;margin:14px 0;}
.doc-label{position:absolute;top:-9px;left:14px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;padding:2px 9px;background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:999px;}
.doc-label.current{color:var(--muted);}
.doc-label.proposed{color:var(--gold);border-color:rgba(201,168,76,.35);background:var(--bg);}
.force-show .msg-actions,.force-show .msg-time,.force-show .msg-foot{opacity:1 !important;}
.messages.doc-messages{overflow:visible;display:block;}
.messages-inner.doc-inner{padding:14px 16px;}
.approval-card.doc-visible,.clarify-card.doc-visible{display:block;}
.doc-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(440px,1fr));gap:14px;}
/* ──────────────────────────────────────────────────────────────
Proposed two-stage elements (prefix .p2s-)
The proposal introduces one container (.p2s-stage1) that wraps
the execution history (thinking + tool cards) and one visual
treatment (.p2s-answer) for the final-answer segment. The same
DOM can be rendered in three modes:
.p2s-stage1.is-live → Working timer + expanded history
.p2s-stage1.is-settled → Collapsed to one-line summary
.p2s-stage1.is-settled.is-open → expanded on demand
Everything else (thinking-card, tool-card-row, msg-body) is the
existing app CSS unchanged.
────────────────────────────────────────────────────────────── */
/* Worklog bar — the header of Stage 1.
Aligns with every other rail child via --msg-rail / --msg-max. */
.p2s-worklog{
display:flex;align-items:center;gap:10px;
margin:4px 0 6px var(--msg-rail);
max-width:var(--msg-max);
padding:8px 12px;
border:1px solid var(--border);
border-radius:10px;
background:rgba(255,255,255,.025);
font-size:12px;color:var(--muted);
cursor:pointer;user-select:none;
transition:border-color .15s,background .15s;
}
.p2s-worklog:hover{border-color:var(--border2);background:rgba(255,255,255,.04);}
.p2s-worklog-dot{
width:8px;height:8px;border-radius:50%;background:var(--gold);flex-shrink:0;
box-shadow:0 0 0 0 rgba(201,168,76,.4);
}
.p2s-stage1.is-live .p2s-worklog-dot{
animation:p2sPulse 1.4s ease-in-out infinite;
}
.p2s-stage1.is-settled .p2s-worklog-dot{
background:var(--muted);opacity:.6;
}
@keyframes p2sPulse{
0%,100%{box-shadow:0 0 0 0 rgba(201,168,76,.45);}
50%{box-shadow:0 0 0 6px rgba(201,168,76,0);}
}
.p2s-worklog-label{color:var(--text);font-weight:500;}
.p2s-worklog-stats{margin-left:auto;display:flex;gap:12px;color:var(--muted);font-size:11.5px;}
.p2s-worklog-stats b{color:var(--text);font-weight:600;}
.p2s-worklog-caret{
display:inline-block;width:14px;height:14px;line-height:14px;text-align:center;
color:var(--muted);font-size:10px;transition:transform .2s;
margin-left:6px;
}
.p2s-stage1.is-live .p2s-worklog-caret{display:none;}
.p2s-stage1.is-settled.is-open .p2s-worklog-caret{transform:rotate(90deg);}
/* Stage 1 body — holds thinking + tool cards + round separators. */
.p2s-stage1-body{
overflow:hidden;
transition:max-height .35s ease,opacity .25s ease;
}
.p2s-stage1.is-live .p2s-stage1-body,
.p2s-stage1.is-settled.is-open .p2s-stage1-body{
max-height:2000px;opacity:1;
}
.p2s-stage1.is-settled:not(.is-open) .p2s-stage1-body{
max-height:0;opacity:0;pointer-events:none;
}
/* Round separator — shown inside Stage 1 between execution rounds. */
.p2s-round-sep{
display:flex;align-items:center;gap:10px;
margin:10px 0 6px var(--msg-rail);
max-width:var(--msg-max);
color:var(--muted);
font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
}
.p2s-round-sep::before,.p2s-round-sep::after{
content:"";flex:1;height:1px;background:var(--border);
}
/* Stage 1 → Stage 2 transition divider. */
.p2s-transition{
margin:14px 0 10px var(--msg-rail);
max-width:var(--msg-max);
height:1px;
background:linear-gradient(
to right,transparent,var(--border) 20%,var(--border) 80%,transparent
);
}
/* Stage 2 — the final answer wrapper.
Design intent: nothing loud. A small "Answer" kicker in gold,
slightly taller line-height, the existing .msg-body styling,
and a gentle top breathing-space. The user arrives at this
block and it *feels* like a conclusion, not another tool row.
*/
.p2s-answer{margin-top:8px;}
.p2s-answer-kicker{
margin:0 0 4px var(--msg-rail);
max-width:var(--msg-max);
font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;
color:var(--gold);opacity:.8;
}
.p2s-answer .msg-body{
font-size:14.5px;line-height:1.78;
}
/* Clarify slot — placed at the transition rather than inline. */
.p2s-clarify-slot{
margin:12px 0 4px var(--msg-rail);
max-width:var(--msg-max);
}
.p2s-clarify-slot .clarify-card{margin:0;}
/* Comparison-grid accents. */
.doc-compare-caption{
font-size:11px;color:var(--muted);text-align:center;padding:6px 0;
}
</style>
</head>
<body>
<header class="doc-header">
<div class="doc-title">
Two-Stage Chat UX — Proposal for <a href="https://github.com/nesquena/hermes-webui/issues/536" target="_blank">issue #536</a>
<small>Companion to <a href="./index.html">index.html</a> — shows <em>Working → Final answer</em> as a distinct two-phase interaction model.</small>
</div>
<div class="doc-toggles">
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
<button data-theme-btn="default">Default</button>
<button data-theme-btn="slate" class="on">Slate</button>
<button data-theme-btn="light">Light</button>
<button data-theme-btn="solarized">Solarized</button>
<button data-theme-btn="monokai">Monokai</button>
<button data-theme-btn="nord">Nord</button>
<button data-theme-btn="oled">OLED</button>
</div>
</header>
<main class="doc-main">
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">0 · The model</div>
<h2 class="doc-h">One turn, two stages</h2>
<p class="doc-note">
Today an assistant turn is a flat stream: thinking card → tool cards → answer, all stacked
inline with equal visual weight. The proposal wraps the execution history in a
<code>.p2s-stage1</code> container with a <em>worklog bar</em> as its header, and marks the
final answer as <code>.p2s-answer</code>. The same DOM renders three ways:
</p>
<ul class="doc-note" style="padding-left:18px;list-style:disc;">
<li><b>Live</b> — worklog shows <em>Working… 0:42 · 2 tools</em> with a pulsing dot; history is fully visible.</li>
<li><b>Settled</b> — worklog collapses to a single line (<em>Worked 1:42 · 4 tools · 2 thinking</em>); final answer sits below as the calm conclusion.</li>
<li><b>Settled + opened</b> — user clicks the worklog to re-expand the history for audit.</li>
</ul>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">1 · Current vs proposed — settled turn</div>
<h2 class="doc-h">Side-by-side comparison</h2>
<p class="doc-note">
Same turn, same tool calls, same answer. Left is what #587 ships today. Right is the
proposal: execution history collapses to a one-line summary; the final answer stands alone
with a small <em>Answer</em> kicker.
</p>
<div class="doc-grid-2">
<!-- CURRENT ──────────────────────────────────────────────── -->
<div class="doc-card"><span class="doc-label current">Current (PR #587)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user">
<div class="msg-body"><p>Does our dev server pick up the workspace from an env var or a flag?</p></div>
</div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 3.1s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Check how the CLI resolves workspace:
grep for HERMES_WORKSPACE and --workspace
inspect argv vs env precedence.</pre></div>
</div>
</div></div>
</div>
<div class="tool-card-row">
<div class="tool-card open">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "HERMES_WORKSPACE" . · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
<div class="tool-card-detail">
<div class="tool-card-result"><pre>cli/main.py:14:WORKSPACE_ENV = "HERMES_WORKSPACE"
cli/main.py:92: ws = os.getenv(WORKSPACE_ENV) or args.workspace</pre></div>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">cli/main.py · 148 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>Both work, but <strong>env wins</strong>. The CLI reads
<code>HERMES_WORKSPACE</code> first and only falls back to the
<code>--workspace</code> flag if the env var is unset.</p>
<p>So in practice:</p>
<ul>
<li>CI / daemons → set the env var.</li>
<li>Ad-hoc runs → pass <code>--workspace</code>.</li>
</ul>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Everything stacks equally — the answer is just the next block.</div>
</div>
<!-- PROPOSED ─────────────────────────────────────────────── -->
<div class="doc-card"><span class="doc-label proposed">Proposed — two-stage, settled</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user">
<div class="msg-body"><p>Does our dev server pick up the workspace from an env var or a flag?</p></div>
</div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<!-- Stage 1 — settled, collapsed to summary (click to expand) -->
<div class="p2s-stage1 is-settled" data-p2s-toggle>
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Worked for 0:08</span>
<span class="p2s-worklog-stats">
<span><b>2</b> tools</span>
<span><b>1</b> thinking round</span>
</span>
<span class="p2s-worklog-caret"></span>
</div>
<div class="p2s-stage1-body">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 3.1s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Check how the CLI resolves workspace:
grep for HERMES_WORKSPACE and --workspace
inspect argv vs env precedence.</pre></div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "HERMES_WORKSPACE" . · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">cli/main.py · 148 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Stage 2 — the final answer -->
<div class="p2s-transition"></div>
<div class="p2s-answer">
<div class="p2s-answer-kicker">Answer</div>
<div class="msg-body">
<p>Both work, but <strong>env wins</strong>. The CLI reads
<code>HERMES_WORKSPACE</code> first and only falls back to the
<code>--workspace</code> flag if the env var is unset.</p>
<p>So in practice:</p>
<ul>
<li>CI / daemons → set the env var.</li>
<li>Ad-hoc runs → pass <code>--workspace</code>.</li>
</ul>
</div>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Click the worklog bar to expand the execution history.</div>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">2 · Stage 1 · Live run</div>
<h2 class="doc-h">Working timer + live execution history</h2>
<p class="doc-note">
The worklog bar at the top is the anchor for the whole active run: pulsing dot, elapsed
timer that ticks every second, and live counts that increment as tool cards resolve.
Thinking cards and tool cards render inside <code>.p2s-stage1-body</code> exactly as today.
A <em>Round N</em> separator is inserted when the agent starts a new reasoning/tool cycle.
</p>
<div class="doc-card"><span class="doc-label proposed">.p2s-stage1.is-live — Round 1 done, Round 2 running</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="p2s-stage1 is-live">
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Working… <span id="p2sTimer">0:42</span></span>
<span class="p2s-worklog-stats">
<span><b>3</b> tools</span>
<span><b>2</b> thinking</span>
</span>
</div>
<div class="p2s-stage1-body">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 2.4s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Need to map the streaming code path first,
then check the persistence layer.</pre></div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">api/streaming.py · 612 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "tool_call_id" api/ · exit 0 · 88ms</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="p2s-round-sep">Round 2</div>
<div class="thinking-card">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 1.8s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Streaming looks fine — drill into how
tool_calls get attached before save.</pre></div>
</div>
<div class="tool-card-row">
<div class="tool-card tool-card-running">
<div class="tool-card-header">
<span class="tool-card-running-dot"></span>
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">pytest tests/test_tool_call_persistence.py -q</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">3 · Approve vs Clarify — placement</div>
<h2 class="doc-h">Approvals stay in Stage 1; Clarify moves to the transition</h2>
<p class="doc-note">
Per the issue: <em>approvals are part of doing the work</em> (they gate a single tool),
<em>clarifications stabilise the answer path</em> (they precede the conclusion). The
proposal keeps <code>.approval-card</code> inline among tool cards, and places
<code>.clarify-card</code> at the Stage 1 → Stage 2 seam, above the final answer.
</p>
<div class="doc-grid-2">
<!-- Approve inline in Stage 1 -->
<div class="doc-card"><span class="doc-label proposed">Approve card — inline in Stage 1</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="p2s-stage1 is-live">
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Working… 0:18</span>
<span class="p2s-worklog-stats"><span><b>1</b> tool</span></span>
</div>
<div class="p2s-stage1-body">
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">ls -la ~/.hermes/sessions · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="approval-card doc-visible">
<div class="approval-card-header">
<span class="approval-card-icon">🔐</span>
<span class="approval-card-title">Approve command</span>
</div>
<div class="approval-card-body">
<p class="approval-card-desc">Hermes wants to run a potentially destructive command:</p>
<pre class="approval-card-cmd">rm -rf ~/.hermes/sessions/*.json.bak</pre>
</div>
<div class="approval-card-actions">
<button class="approval-btn approve">Approve</button>
<button class="approval-btn deny">Deny</button>
</div>
</div>
</div>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Permission gate sits next to the tools it gates.</div>
</div>
<!-- Clarify at transition -->
<div class="doc-card"><span class="doc-label proposed">Clarify card — Stage 1 → Stage 2 transition</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="p2s-stage1 is-settled" data-p2s-toggle>
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Worked for 0:12</span>
<span class="p2s-worklog-stats"><span><b>2</b> tools</span></span>
<span class="p2s-worklog-caret"></span>
</div>
<div class="p2s-stage1-body">
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">package.json · 48 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">ls src/ · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div>
</div>
<div class="p2s-transition"></div>
<div class="p2s-clarify-slot">
<div class="clarify-card doc-visible">
<div class="clarify-card-header">
<span class="clarify-card-icon"></span>
<span class="clarify-card-title">One quick question before I answer</span>
</div>
<div class="clarify-card-body">
<p>I can wire the dev server either as an <strong>npm script</strong> in the
existing <code>package.json</code>, or as a standalone <strong>CLI
entry-point</strong>. Which would you prefer?</p>
</div>
<div class="clarify-card-actions">
<button class="clarify-opt">npm script</button>
<button class="clarify-opt">CLI entry-point</button>
<button class="clarify-opt">Let Hermes pick</button>
</div>
</div>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Stage 1 is already settled; the answer is paused on clarification.</div>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">4 · Stage 2 · Calm conclusion</div>
<h2 class="doc-h">What the "Answer" stage looks like on its own</h2>
<p class="doc-note">
Three small choices distinguish Stage 2 from a regular text block:
(1) a thin horizontal divider above it, (2) a tiny gold <em>Answer</em> kicker aligned to
the text rail, (3) a slightly taller line-height. No heavy borders, no boxed treatment —
the emphasis comes from <em>what is missing around it</em>, not ornament.
</p>
<div class="doc-card"><span class="doc-label proposed">.p2s-answer (Stage 1 collapsed above)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="p2s-stage1 is-settled" data-p2s-toggle>
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Worked for 1:42</span>
<span class="p2s-worklog-stats">
<span><b>4</b> tools</span>
<span><b>2</b> thinking</span>
<span><b>1</b> approval</span>
</span>
<span class="p2s-worklog-caret"></span>
</div>
<div class="p2s-stage1-body">
<div class="thinking-card"><div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 2.4s</span><span class="thinking-card-toggle"></span></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">api/streaming.py</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">grep -rn "tool_call_id" api/</span><span class="tool-card-toggle"></span></div></div></div>
<div class="p2s-round-sep">Round 2</div>
<div class="thinking-card"><div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 1.8s</span><span class="thinking-card-toggle"></span></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">pytest -q · exit 0 · 2.4s</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">✍️</span><span class="tool-card-name">edit_file</span><span class="tool-card-preview">api/streaming.py · +12 3</span><span class="tool-card-toggle"></span></div></div></div>
</div>
</div>
<div class="p2s-transition"></div>
<div class="p2s-answer">
<div class="p2s-answer-kicker">Answer</div>
<div class="msg-body">
<p>Tool-call persistence was breaking because <code>session.tool_calls</code> was
written <em>after</em> <code>s.save()</code> in <code>api/streaming.py</code>.
I moved the attach step above the save, and added a fallback that reconstructs
ordering from live tool-progress events when <code>tool_call_id</code> is absent
on older sessions.</p>
<p>Net result:</p>
<ul>
<li>Reloading mid-stream now preserves every tool card with args + output snippet.</li>
<li>Last-turn reasoning survives reload.</li>
<li>No schema migration needed — old sessions degrade gracefully.</li>
</ul>
<p>Covered by the new regression in <code>tests/test_tool_call_persistence.py</code>.</p>
</div>
<div class="msg-foot" style="opacity:1;padding-left:var(--msg-rail);">
<span class="msg-time">11:42 AM · 2,481 tokens · 1.42s</span>
<span class="msg-actions">
<button class="msg-act" title="Copy"></button>
<button class="msg-act" title="Regenerate"></button>
</span>
</div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">5 · Open-question answers (picked defaults)</div>
<h2 class="doc-h">What this proposal commits to</h2>
<div class="doc-card" style="padding:16px 20px;">
<ul style="color:var(--muted);font-size:13px;line-height:1.85;list-style:disc;padding-left:22px;margin:0;">
<li><b style="color:var(--text);">Stage 1 on settle →</b> <em>partial</em> collapse to a
single worklog bar with counts. Click to re-expand. No "nuke to black box", no "keep
everything open forever".</li>
<li><b style="color:var(--text);">Final answer placement →</b> sits <em>beneath</em> Stage 1,
not replacing it. Visual distinction comes from the divider + kicker + spacing, not from
a two-panel layout.</li>
<li><b style="color:var(--text);">Clarify placement →</b> at the Stage 1 → Stage 2 seam.
Approvals stay inline with tools.</li>
<li><b style="color:var(--text);">Timer →</b> lives on Stage 1 only. Stops when the agent
emits the first Stage 2 token; final label becomes "Worked for N:NN".</li>
<li><b style="color:var(--text);">Signal for "answer has started" →</b> first assistant
text delta after all tool calls have resolved and no new <code>tool_use</code> is pending
in the current round. Already present in the SSE stream per maintainer comment.</li>
</ul>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">6 · DOM cheat-sheet</div>
<h2 class="doc-h">What changes vs index.html</h2>
<div class="doc-card" style="padding:14px 18px;">
<h3 style="font-size:13px;color:var(--text);margin:0 0 8px;">New wrappers</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>.p2s-stage1[is-live|is-settled][is-open]</code> — wraps the execution history inside an <code>.assistant-segment</code>.</li>
<li><code>.p2s-worklog</code> — header of Stage 1. Pulsing dot + label + counts + caret. Clickable when settled.</li>
<li><code>.p2s-stage1-body</code> — holds <code>.thinking-card</code> + <code>.tool-card-row</code> + <code>.p2s-round-sep</code>. Animated via <code>max-height</code>.</li>
<li><code>.p2s-round-sep</code> — inline horizontal separator between tool/reasoning rounds.</li>
<li><code>.p2s-transition</code> — thin gradient divider between Stage 1 and Stage 2.</li>
<li><code>.p2s-answer</code> — wraps the final <code>.msg-body</code> + <code>.msg-foot</code>.</li>
<li><code>.p2s-answer-kicker</code> — small gold <em>Answer</em> label.</li>
<li><code>.p2s-clarify-slot</code> — placement slot for <code>.clarify-card</code> at the Stage 1/2 seam.</li>
</ul>
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Unchanged</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>.thinking-card</code>, <code>.tool-card</code>, <code>.approval-card</code>, <code>.clarify-card</code>, <code>.msg-body</code>, <code>.msg-foot</code> — all existing app CSS and existing markup.</li>
<li><code>.assistant-turn-blocks</code> and <code>.assistant-segment</code> remain the top-level wrappers.</li>
<li>Tool cards still live as <code>.tool-card-row</code> siblings — now nested <em>inside</em> <code>.p2s-stage1-body</code> rather than as direct children of <code>.messages-inner</code>.</li>
</ul>
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Implementation notes</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li>Renderer in <code>static/messages.js</code> wraps an assistant turn's non-final blocks in <code>.p2s-stage1-body</code> and appends the <code>.p2s-worklog</code> header once; toggles <code>is-live</code>/<code>is-settled</code> based on <code>data-live-assistant</code>.</li>
<li><code>static/boot.js</code> SSE handler ticks the timer while <code>is-live</code>, increments counts on each <code>tool_use</code>, and flips the class when the first Stage 2 delta arrives.</li>
<li>Persistence: no schema change needed — the worklog summary can be derived on reload from the existing persisted tool-call list + thinking rounds.</li>
</ul>
</div>
</section>
</main>
<script>
// Theme picker (matches index.html)
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.themeBtn;
if (t === 'default') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', t);
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
});
});
// Existing thinking/tool cards click-to-toggle.
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
h.addEventListener('click', (e) => {
e.stopPropagation();
h.parentElement.classList.toggle('open');
});
});
// Click the worklog bar on a settled Stage 1 to expand/collapse the history.
document.querySelectorAll('.p2s-stage1[data-p2s-toggle] .p2s-worklog').forEach(bar => {
bar.addEventListener('click', () => {
const stage = bar.closest('.p2s-stage1');
if (!stage.classList.contains('is-settled')) return;
stage.classList.toggle('is-open');
});
});
// Live timer demo in section 2 — ticks so the page feels alive.
(function(){
const el = document.getElementById('p2sTimer');
if (!el) return;
let [m, s] = el.textContent.split(':').map(Number);
setInterval(() => {
s = (s + 1) % 60;
if (s === 0) m += 1;
el.textContent = m + ':' + String(s).padStart(2,'0');
}, 1000);
})();
</script>
</body>
</html>

View File

@@ -3,21 +3,51 @@ Hermes Web UI -- Main server entry point.
Thin routing shell: imports Handler, delegates to api/routes.py, runs server.
All business logic lives in api/*.
"""
import logging
import socket
import sys
import time
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
from api.auth import check_auth
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
from api.helpers import j
from api.helpers import j, get_profile_cookie
from api.profiles import set_request_profile, clear_request_profile
from api.routes import handle_get, handle_post
from api.startup import auto_install_agent_deps, fix_credential_permissions
from api.updates import WEBUI_VERSION
class QuietHTTPServer(ThreadingHTTPServer):
"""Custom HTTP server that silently handles common network errors."""
def handle_error(self, request, client_address):
"""Override to suppress logging for common client disconnect errors."""
exc_type, exc_value, _ = sys.exc_info()
# Silently ignore common connection errors caused by client disconnects
if exc_type in (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
return
# Also handle socket errors that indicate client disconnect
if exc_type is socket.error:
# errno 54 is Connection reset by peer on macOS/BSD
# errno 104 is Connection reset by peer on Linux
if exc_value.errno in (54, 104, 32): # ECONNRESET, EPIPE
return
# For other errors, use default logging
super().handle_error(request, client_address)
class Handler(BaseHTTPRequestHandler):
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
server_version = 'HermesWebUI/0.2'
_ver_suffix = WEBUI_VERSION.removeprefix('v')
server_version = ('HermesWebUI/' + _ver_suffix) if _ver_suffix != 'unknown' else 'HermesWebUI'
def log_message(self, fmt, *args): pass # suppress default Apache-style log
def log_request(self, code: str='-', size: str='-') -> None:
@@ -35,6 +65,10 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
self._req_t0 = time.time()
# Per-request profile context from cookie (issue #798)
cookie_profile = get_profile_cookie(self)
if cookie_profile:
set_request_profile(cookie_profile)
try:
parsed = urlparse(self.path)
if not check_auth(self, parsed): return
@@ -44,9 +78,15 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
return j(self, {'error': 'Internal server error'}, status=500)
finally:
clear_request_profile()
def do_POST(self) -> None:
self._req_t0 = time.time()
# Per-request profile context from cookie (issue #798)
cookie_profile = get_profile_cookie(self)
if cookie_profile:
set_request_profile(cookie_profile)
try:
parsed = urlparse(self.path)
if not check_auth(self, parsed): return
@@ -56,6 +96,8 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
return j(self, {'error': 'Internal server error'}, status=500)
finally:
clear_request_profile()
def main() -> None:
@@ -118,7 +160,7 @@ def main() -> None:
except Exception as e:
print(f'[!!] WARNING: Gateway watcher failed to start: {e}', flush=True)
httpd = ThreadingHTTPServer((HOST, PORT), Handler)
httpd = QuietHTTPServer((HOST, PORT), Handler)
# ── TLS/HTTPS setup (optional) ─────────────────────────────────────────
from api.config import TLS_ENABLED, TLS_CERT, TLS_KEY
@@ -148,7 +190,7 @@ def main() -> None:
from api.gateway_watcher import stop_watcher
stop_watcher()
except Exception:
pass
logger.debug("Failed to stop gateway watcher during shutdown")
if __name__ == '__main__':
main()

View File

@@ -2,7 +2,7 @@ async function cancelStream(){
const streamId = S.activeStreamId;
if(!streamId) return;
try{
await fetch(new URL(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{credentials:'include'});
await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{credentials:'include'});
}catch(e){/* cancel request failed — cleanup below still runs */}
// Clear status unconditionally after the cancel request completes.
// The SSE cancel event may also fire, but if the connection is already
@@ -40,7 +40,10 @@ function _setWorkspacePanelMode(mode){
if(!layout||!panel)return;
_workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed';
const open=_workspacePanelMode!=='closed';
document.documentElement.dataset.workspacePanel=open?'open':'closed';
// Persist open/closed across refreshes (browse/preview → open; closed → closed)
// Do NOT overwrite the user's "keep open" preference — only track runtime state
// so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting.
localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');
layout.classList.toggle('workspace-panel-collapsed',!open);
if(_isCompactWorkspaceViewport()){
@@ -118,6 +121,10 @@ function syncWorkspacePanelUI(){
if(clearBtn){
clearBtn.disabled=!isOpen;
clearBtn.title=hasPreview?'Close preview':'Hide workspace panel';
// On desktop, only show the X button when a file preview is open.
// In browse mode the chevron (btnCollapseWorkspacePanel) already serves
// as the close control, so showing both produces a duplicate X.
if(!isCompact) clearBtn.style.display=hasPreview?'':'none';
}
}
@@ -151,11 +158,7 @@ function toggleWorkspacePanel(force){
openWorkspacePanel(nextMode);
}
function mobileSwitchPanel(name){
// Switch the panel content view
switchPanel(name);
// For non-chat panels (tasks, skills, memory, spaces), open the sidebar
// so the panel is visible. For 'chat', the content is in the main area —
// just close the sidebar so the chat view is unobstructed.
if(name==='chat'){
closeMobileSidebar();
} else {
@@ -166,98 +169,220 @@ function mobileSwitchPanel(name){
if(overlay)overlay.classList.add('visible');
}
}
// Update bottom nav active state
document.querySelectorAll('.mobile-nav-btn').forEach(btn=>{
btn.classList.toggle('active',btn.dataset.panel===name);
});
}
$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();};
$('btnSend').onclick=()=>{
if(window._micActive){
window._micPendingSend=true;
_stopMic();
return;
}
send();
};
$('btnAttach').onclick=()=>$('fileInput').click();
// ── Voice input (Web Speech API) ─────────────────────────────────────────
// ── Voice input (Web Speech API + MediaRecorder fallback) ───────────────────
(function(){
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
if(!SpeechRecognition) return; // Browser unsupported — mic button stays hidden
const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder);
if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden
// Persist SR failure across reloads (e.g. Tailscale/network error)
const _micForceMediaRecorderKey='mic_force_mediarecorder';
let _forceMediaRecorder=!SpeechRecognition||localStorage.getItem(_micForceMediaRecorderKey)==='1';
const btn=$('btnMic');
const status=$('micStatus');
const ta=$('msg');
btn.style.display=''; // Show button — browser supports speech
const recognition=new SpeechRecognition();
recognition.continuous=false;
recognition.interimResults=true;
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
const statusText=status?status.querySelector('.status-text'):null;
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
let recognition=(!_forceMediaRecorder&&SpeechRecognition)?new SpeechRecognition():null;
let mediaRecorder=null;
let mediaStream=null;
let audioChunks=[];
let _finalText='';
let _prefix='';
let _isRecording=false;
function _setRecording(on){
window._micActive=on;
btn.classList.toggle('recording',on);
status.style.display=on?'':'none';
if(statusText) statusText.textContent=on?'Listening':'Listening';
if(!on){ _finalText=''; _prefix=''; }
}
recognition.onstart=()=>{ _finalText=''; };
recognition.onresult=(event)=>{
let interim='';
let final=_finalText;
for(let i=event.resultIndex;i<event.results.length;i++){
const t=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=t; _finalText=final; }
else{ interim+=t; }
}
// Append to whatever was already in the textarea before mic started
ta.value=_prefix+(final||interim);
autoResize();
};
recognition.onend=()=>{
// Commit: prefix + final transcription; trim trailing space if prefix was non-empty
const committed=_finalText
function _commitTranscript(text){
const clean=(text||'').trim();
const committed=clean
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
? _prefix+' '+_finalText.trimStart()
: _prefix+_finalText)
: ta.value; // no speech detected — leave whatever is there
_setRecording(false);
? _prefix+' '+clean.trimStart()
: _prefix+clean)
: ta.value;
ta.value=committed;
autoResize();
};
if(window._micPendingSend){
window._micPendingSend=false;
send();
}
}
recognition.onerror=(event)=>{
_setRecording(false);
const msgs={
'not-allowed':t('mic_denied'),
'no-speech':t('mic_no_speech'),
'network':t('mic_network'),
};
showToast(msgs[event.error]||t('mic_error')+event.error);
};
async function _transcribeBlob(blob){
const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
const form=new FormData();
form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`}));
setComposerStatus('Transcribing…');
try{
const res=await fetch('api/transcribe',{method:'POST',body:form});
const data=await res.json().catch(()=>({}));
if(!res.ok) throw new Error(data.error||'Transcription failed');
_commitTranscript(data.transcript||'');
}catch(err){
window._micPendingSend=false;
showToast(err.message||t('mic_network'));
}finally{
setComposerStatus('');
}
}
function _stopTracks(){
if(mediaStream){
mediaStream.getTracks().forEach(track=>track.stop());
mediaStream=null;
}
}
function _stopMic(){
if(window._micActive){ recognition.stop(); }
if(!window._micActive) return;
if(recognition){
recognition.stop();
return;
}
if(mediaRecorder&&mediaRecorder.state!=='inactive'){
mediaRecorder.stop();
return;
}
_setRecording(false);
_stopTracks();
}
window._stopMic=_stopMic; // expose for send-guard above
btn.onclick=()=>{
if(recognition && !_forceMediaRecorder){
recognition.continuous=false;
recognition.interimResults=true;
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
recognition.onstart=()=>{ _finalText=''; };
recognition.onresult=(event)=>{
let interim='';
let final=_finalText;
for(let i=event.resultIndex;i<event.results.length;i++){
const t=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=t; _finalText=final; }
else{ interim+=t; }
}
ta.value=_prefix+(final||interim);
autoResize();
};
recognition.onend=()=>{
const committed=_finalText
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
? _prefix+' '+_finalText.trimStart()
: _prefix+_finalText)
: ta.value;
_setRecording(false);
ta.value=committed;
autoResize();
if(window._micPendingSend){
window._micPendingSend=false;
send();
}
};
recognition.onerror=(event)=>{
_setRecording(false);
window._micPendingSend=false;
_isRecording=false;
if(event.error==='network'||event.error==='not-allowed'){
// Persist SR failure: next reload will skip SpeechRecognition
localStorage.setItem(_micForceMediaRecorderKey,'1');
_forceMediaRecorder=true;
recognition=null;
}
const msgs={
'not-allowed':t('mic_denied'),
'no-speech':t('mic_no_speech'),
'network':t('mic_network'),
};
showToast(msgs[event.error]||t('mic_error')+event.error);
};
}
btn.onclick=async()=>{
// Race-condition guard: ignore rapid double-clicks
if(_isRecording){
_stopMic();
_isRecording=false;
return;
}
if(window._micActive){
recognition.stop();
// _setRecording(false) will be called by onend
} else {
_finalText='';
// Snapshot existing textarea content so we append rather than replace
_prefix=ta.value;
_stopMic();
return;
}
_isRecording=true;
_finalText='';
_prefix=ta.value;
if(recognition && !_forceMediaRecorder){
recognition.start();
_setRecording(true);
return;
}
if(!_canRecordAudio){
_isRecording=false;
showToast(t('mic_network'));
return;
}
try{
mediaStream=await navigator.mediaDevices.getUserMedia({audio:true});
const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg'];
const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||'';
mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined);
audioChunks=[];
mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
mediaRecorder.onerror=()=>{
_isRecording=false;
_setRecording(false);
window._micPendingSend=false;
_stopTracks();
showToast(t('mic_network'));
};
mediaRecorder.onstop=async()=>{
_isRecording=false;
const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
_setRecording(false);
_stopTracks();
if(blob.size){ await _transcribeBlob(blob); }
else if(window._micPendingSend){
window._micPendingSend=false;
}
};
mediaRecorder.start();
_setRecording(true);
}catch(err){
_isRecording=false;
window._micPendingSend=false;
_stopTracks();
showToast(t('mic_denied'));
}
};
})();
window._micActive=window._micActive||false;
window._micPendingSend=window._micPendingSend||false;
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();};
$('btnDownload').onclick=()=>{
if(!S.session)return;
const blob=new Blob([transcript()],{type:'text/markdown'});
@@ -282,8 +407,7 @@ $('importFileInput').onchange=async(e)=>{
if(res.ok&&res.session){
await loadSession(res.session.session_id);
await renderSessionList();
const overlay=$('settingsOverlay');
if(overlay) overlay.style.display='none';
if(_currentPanel==='settings') switchPanel('chat');
showToast(t('session_imported'));
}
}catch(err){
@@ -321,15 +445,27 @@ $('modelSelect').onchange=async()=>{
const warn=_checkProviderMismatch(selectedModel);
if(warn&&typeof showToast==='function') showToast(warn,4000);
}
// Notify user that model changes only take effect in the next conversation (#419)
if(S.messages && S.messages.length > 0 && typeof showToast==='function'){
showToast('Model change takes effect in your next conversation', 3000);
}
};
$('msg').addEventListener('input',()=>{
autoResize();
updateSendBtn();
const text=$('msg').value;
if(text.startsWith('/')&&text.indexOf('\n')===-1){
const prefix=text.slice(1);
const matches=getMatchingCommands(prefix);
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
if(typeof getSlashAutocompleteMatches==='function'){
getSlashAutocompleteMatches(text).then(matches=>{
if(($('msg').value||'')!==text) return;
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
});
}else{
const prefix=text.slice(1);
const matches=getMatchingCommands(prefix);
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
}
if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete();
} else {
hideCmdDropdown();
}
@@ -343,7 +479,12 @@ $('msg').addEventListener('keydown',e=>{
if(e.key==='ArrowDown'){e.preventDefault();navigateCmdDropdown(1);return;}
if(e.key==='Tab'){e.preventDefault();selectCmdDropdownItem();return;}
if(e.key==='Escape'){e.preventDefault();hideCmdDropdown();return;}
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();selectCmdDropdownItem();return;}
if(e.key==='Enter'&&!e.shiftKey){
if(e.isComposing){return;}
e.preventDefault();
selectCmdDropdownItem();
return;
}
}
// Send key: respect user preference.
// On touch-primary devices (software keyboard), default to Enter = newline
@@ -351,6 +492,7 @@ $('msg').addEventListener('keydown',e=>{
// The 'ctrl+enter' setting also uses this behavior (Enter = newline).
// Users can override in Settings by explicitly choosing 'enter' mode.
if(e.key==='Enter'){
if(e.isComposing){return;}
const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter';
if(window._sendKey==='ctrl+enter'||_mobileDefault){
if(e.ctrlKey||e.metaKey){e.preventDefault();send();}
@@ -374,12 +516,17 @@ document.addEventListener('keydown',async e=>{
}
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
e.preventDefault();
if(!S.busy){await newSession();await renderSessionList();$('msg').focus();}
if(!S.busy){await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();}
}
if(e.key==='Escape'){
// Close settings overlay if open
const settingsOverlay=$('settingsOverlay');
if(settingsOverlay&&settingsOverlay.style.display!=='none'){_closeSettingsPanel();return;}
// Close onboarding overlay if open (skip/dismiss the wizard)
const onboardingOverlay=$('onboardingOverlay');
if(onboardingOverlay&&onboardingOverlay.style.display!=='none'){
if(typeof skipOnboarding==='function') skipOnboarding();
return;
}
// Close settings panel if active
if(_currentPanel==='settings'){_closeSettingsPanel();return;}
// Close workspace dropdown
closeWsDropdown();
// Clear session search
@@ -463,6 +610,164 @@ window.addEventListener('resize',()=>{
};
})();
// ── Appearance helpers (theme = light/dark/system, skin = accent color) ──────
const _SKINS=[
{name:'Default', colors:['#FFD700','#FFBF00','#CD7F32']},
{name:'Ares', colors:['#FF4444','#CC3333','#992222']},
{name:'Mono', colors:['#CCCCCC','#999999','#666666']},
{name:'Slate', colors:['#334155','#475569','#64748b']},
{name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']},
{name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
];
const _VALID_THEMES=new Set(['system','dark','light']);
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
const _LEGACY_THEME_MAP={
slate:{theme:'dark',skin:'slate'},
solarized:{theme:'dark',skin:'poseidon'},
monokai:{theme:'dark',skin:'sisyphus'},
nord:{theme:'dark',skin:'slate'},
oled:{theme:'dark',skin:'default'},
};
let _systemThemeMq=null;
let _onSystemThemeChange=null;
function _normalizeAppearance(theme,skin){
const rawTheme=typeof theme==='string'?theme.trim().toLowerCase():'';
const rawSkin=typeof skin==='string'?skin.trim().toLowerCase():'';
const legacy=_LEGACY_THEME_MAP[rawTheme];
const nextTheme=legacy?legacy.theme:(_VALID_THEMES.has(rawTheme)?rawTheme:'dark');
const nextSkin=_VALID_SKINS.has(rawSkin)?rawSkin:(legacy?legacy.skin:'default');
return {theme:nextTheme,skin:nextSkin};
}
function _setResolvedTheme(isDark){
document.documentElement.classList.toggle('dark',!!isDark);
const link=document.getElementById('prism-theme');
if(!link) return;
const want=isDark
?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
:'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
const wantIntegrity=isDark
?'sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A'
:'sha384-rCCjoCPCsizaAAYVoz1Q0CmCTvnctK0JkfCSjx7IIxexTBg+uCKtFYycedUjMyA2';
if(link.href!==want){ link.integrity=wantIntegrity; link.href=want; }
}
function _applyTheme(name){
const normalized=_normalizeAppearance(name,'default');
if(_systemThemeMq&&_onSystemThemeChange){
_systemThemeMq.removeEventListener('change',_onSystemThemeChange);
_systemThemeMq=null;
_onSystemThemeChange=null;
}
if(normalized.theme==='system'){
_systemThemeMq=window.matchMedia('(prefers-color-scheme:dark)');
_onSystemThemeChange=()=>_setResolvedTheme(_systemThemeMq.matches);
_setResolvedTheme(_systemThemeMq.matches);
_systemThemeMq.addEventListener('change',_onSystemThemeChange);
return;
}
_setResolvedTheme(normalized.theme==='dark');
}
function _applySkin(name){
const key=(name||'default').toLowerCase();
if(key==='default') delete document.documentElement.dataset.skin;
else document.documentElement.dataset.skin=key;
}
function _pickTheme(name){
const currentSkin=localStorage.getItem('hermes-skin');
const appearance=_normalizeAppearance(name,currentSkin);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
_syncThemePicker(appearance.theme);
_syncSkinPicker(appearance.skin);
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
const hidden=$('settingsTheme');
if(hidden) hidden.value=appearance.theme;
const skinHidden=$('settingsSkin');
if(skinHidden) skinHidden.value=appearance.skin;
}
function _pickSkin(name){
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),name);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
_syncThemePicker(appearance.theme);
_syncSkinPicker(appearance.skin);
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
const hidden=$('settingsSkin');
if(hidden) hidden.value=appearance.skin;
const themeHidden=$('settingsTheme');
if(themeHidden) themeHidden.value=appearance.theme;
}
function _syncThemePicker(active){
document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
const sel=btn.dataset.themeVal===active;
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
});
}
function _syncSkinPicker(active){
document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
const sel=btn.dataset.skinVal===active;
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
});
}
function _applyFontSize(size){
if(size&&size!=='default'){
document.documentElement.dataset.fontSize=size;
} else {
delete document.documentElement.dataset.fontSize;
}
}
function _pickFontSize(size){
localStorage.setItem('hermes-font-size',size);
_applyFontSize(size);
_syncFontSizePicker(size);
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
const hidden=$('settingsFontSize');
if(hidden) hidden.value=size;
}
function _syncFontSizePicker(active){
document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn').forEach(btn=>{
const sel=btn.dataset.fontSizeVal===(active||'default');
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
});
}
function _buildSkinPicker(activeSkin){
const grid=$('skinPickerGrid');
if(!grid) return;
grid.innerHTML='';
for(const skin of _SKINS){
const key=skin.name.toLowerCase();
const btn=document.createElement('button');
btn.type='button';
btn.className='skin-pick-btn';
btn.dataset.skinVal=key;
btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s';
btn.onclick=()=>_pickSkin(skin.name);
const dots=skin.colors.map(c=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${skin.name}</span>`;
grid.appendChild(btn);
}
_syncSkinPicker((activeSkin||'default').toLowerCase());
}
function applyBotName(){
const name=window._botName||'Hermes';
document.title=name;
@@ -479,7 +784,53 @@ function applyBotName(){
(async()=>{
// Load send key preference
let _bootSettings={};
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);if(s.language&&typeof setLocale==='function'){setLocale(s.language);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};}
try{
const s=await api('/api/settings');
_bootSettings=s;
window._sendKey=s.send_key||'enter';
window._showTokenUsage=!!s.show_token_usage;
window._showCliSessions=!!s.show_cli_sessions;
window._soundEnabled=!!s.sound_enabled;
window._notificationsEnabled=!!s.notifications_enabled;
window._showThinking=s.show_thinking!==false;
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
window._botName=s.bot_name||'Hermes';
if(s.default_model) window._defaultModel=s.default_model;
// Persist default workspace so the blank new-chat page can show it
// and workspace actions (New file/folder) work before the first session (#804).
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
const appearance=_normalizeAppearance(s.theme,s.skin);
localStorage.setItem('hermes-theme',appearance.theme);
_applyTheme(appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applySkin(appearance.skin);
if(typeof setLocale==='function'){
const _lang=typeof resolvePreferredLocale==='function'
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
: (s.language || localStorage.getItem('hermes-lang') || 'en');
setLocale(_lang);
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
}
applyBotName();
}catch(e){
window._sendKey='enter';
window._showTokenUsage=false;
window._showCliSessions=false;
window._soundEnabled=false;
window._notificationsEnabled=false;
window._showThinking=true;
window._sidebarDensity='compact';
window._botName='Hermes';
_bootSettings={check_for_updates:false};
if(typeof setLocale==='function'){
const _lang=typeof resolvePreferredLocale==='function'
? resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))
: (localStorage.getItem('hermes-lang') || 'en');
setLocale(_lang);
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
}
applyBotName();
}
// Non-blocking update check (fire-and-forget, once per tab session)
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
@@ -492,29 +843,49 @@ function applyBotName(){
// Update profile chip label immediately
const profileLabel=$('profileChipLabel');
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
// Fetch available models from server and populate dropdown dynamically
await populateModelDropdown();
// Restore last-used model preference
const savedModel=localStorage.getItem('hermes-webui-model');
if(savedModel && $('modelSelect')){
$('modelSelect').value=savedModel;
// If the value didn't take (model not in list), clear the bad pref
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
}
// Fetch available models without blocking session restore. The static HTML
// options are enough for first paint; the dynamic provider list can settle
// after the saved session is visible.
const _modelDropdownReady=populateModelDropdown().then(()=>{
const savedModel=localStorage.getItem('hermes-webui-model');
if(savedModel && $('modelSelect')){
$('modelSelect').value=savedModel;
// If the value didn't take (model not in list), clear the bad pref
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
else if(typeof syncModelChip==='function') syncModelChip();
}
if(S.session) syncTopbar();
}).catch(()=>{});
window._modelDropdownReady=_modelDropdownReady;
// Pre-load workspace list so sidebar name is correct from first render
await loadWorkspaceList();
await loadOnboardingWizard();
_initResizePanels();
// Restore workspace panel open/closed state from last visit
if(localStorage.getItem('hermes-webui-workspace-panel')==='open'){
_workspacePanelMode='browse';
}
// Workspace panel restore happens AFTER loadSession so we know if
// the session has a workspace — prevents the snap-open-then-closed flash (#576).
// Fix #822: clear any browser-restored value before first render. This
// covers fresh page loads and reloads. The bfcache restore case is handled
// separately below by a `pageshow` listener — the async IIFE here does NOT
// re-run when the browser restores the page from bfcache.
const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = '';
const saved=localStorage.getItem('hermes-webui-session');
if(saved){
try{await loadSession(saved);syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
try{
await loadSession(saved);
// Restore the panel from localStorage when the session has a workspace.
// Preference key takes priority over runtime state so that closing
// the panel via toolbar X doesn't suppress the "keep open" setting.
const panelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
if(S.session&&S.session.workspace&&panelPref){
_workspacePanelMode='browse';
}
S._bootReady=true;
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
catch(e){localStorage.removeItem('hermes-webui-session');}
}
// no saved session - show empty state, wait for user to hit +
S._bootReady=true;
syncTopbar();
syncWorkspacePanelState();
$('emptyState').style.display='';
@@ -522,3 +893,32 @@ function applyBotName(){
// Start real-time gateway session sync if setting is enabled
if(typeof startGatewaySSE==='function') startGatewaySSE();
})();
// Fix #822 (bfcache path): when the browser restores the page from the
// back-forward cache, the async boot IIFE above does NOT re-run, but the
// DOM — including any stale value in #sessionSearch — IS restored. A
// prior search string would silently hide all sessions via the filter in
// renderSessionListFromCache(). Clear the field and re-run the full layout
// sync whenever the page is restored from cache (`event.persisted === true`).
// Fix #1045: also re-run topbar/workspace/panel state so the rail and layout
// chrome aren't left in the stale bfcache snapshot.
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return; // fresh loads are handled by the IIFE above
const _srch = document.getElementById('sessionSearch');
if (_srch) _srch.value = '';
// Close any dropdowns/popovers that were open when the user navigated away.
// bfcache freezes DOM state, so a dropdown left open remains open on restore.
if (typeof closeModelDropdown === 'function') try { closeModelDropdown(); } catch (_) {}
if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {}
if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {}
if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {}
// Re-synchronise layout chrome that the boot IIFE sets up but bfcache
// doesn't re-run. Each call is guarded so missing helpers degrade silently.
if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {}
if (typeof syncWorkspacePanelState === 'function') try { syncWorkspacePanelState(); } catch (_) {}
if (typeof renderSessionListFromCache === 'function') {
try { renderSessionListFromCache(); } catch (_) {}
}
// Restart the gateway SSE watcher — the persisted connection is dead after bfcache
if (typeof startGatewaySSE === 'function') try { startGatewaySSE(); } catch (_) {}
});

View File

@@ -3,18 +3,35 @@
// (no round-trip to the agent) and shows feedback via toast or local message.
const COMMANDS=[
// noEcho:true = action-only commands that don't produce a chat response.
// Commands without noEcho get a user message echoed to the chat (#840).
{name:'help', desc:t('cmd_help'), fn:cmdHelp},
{name:'clear', desc:t('cmd_clear'), fn:cmdClear},
{name:'compact', desc:t('cmd_compact'), fn:cmdCompact},
{name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name'},
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name'},
{name:'new', desc:t('cmd_new'), fn:cmdNew},
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage},
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name'},
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name'},
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
{name:'clear', desc:t('cmd_clear'), fn:cmdClear, noEcho:true},
{name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]', noEcho:true},
{name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact, noEcho:true},
{name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models', noEcho:true},
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name', noEcho:true},
{name:'new', desc:t('cmd_new'), fn:cmdNew, noEcho:true},
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage, noEcho:true},
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true},
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'},
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
{name:'stop', desc:t('cmd_stop'), fn:cmdStop, noEcho:true},
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true},
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true},
{name:'btw', desc:t('cmd_btw'), fn:cmdBtw, arg:'question', noEcho:true},
{name:'background',desc:t('cmd_background'),fn:cmdBackground,arg:'prompt', noEcho:true},
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true},
];
const SLASH_SUBARG_SOURCES={
model:{desc:t('cmd_model'), subArgs:'models'},
personality:{desc:t('cmd_personality'), subArgs:'personalities'},
};
function parseCommand(text){
if(!text.startsWith('/'))return null;
const parts=text.slice(1).split(/\s+/);
@@ -25,23 +42,172 @@ function parseCommand(text){
function executeCommand(text){
const parsed=parseCommand(text);
if(!parsed)return false;
if(!parsed)return null;
const cmd=COMMANDS.find(c=>c.name===parsed.name);
if(!cmd)return false;
cmd.fn(parsed.args);
return true;
if(!cmd)return null;
// A handler may return `false` to opt out of interception — e.g. /reasoning
// with an effort level falls through so the agent's own handler sees it,
// preserving the pre-existing pass-through behaviour for that subcommand.
if(cmd.fn(parsed.args)===false)return null;
// Return noEcho flag so send() knows whether to echo the command as a user message (#840).
return {noEcho:!!cmd.noEcho};
}
function getMatchingCommands(prefix){
const q=prefix.toLowerCase();
return COMMANDS.filter(c=>c.name.startsWith(q));
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
const seen=new Set(matches.map(c=>c.name));
for(const [name, spec] of Object.entries(SLASH_SUBARG_SOURCES)){
if(!name.startsWith(q)||seen.has(name))continue;
matches.push({
name,
desc:spec.desc,
arg:'name',
source:'subarg-command',
});
seen.add(name);
}
for(const skill of _skillCommandCache){
if(!skill.name.startsWith(q)||seen.has(skill.name))continue;
matches.push(skill);
seen.add(skill.name);
}
return matches;
}
let _slashModelCache=null;
let _slashModelCachePromise=null;
let _slashPersonalityCache=null;
let _slashPersonalityCachePromise=null;
function _normalizeSlashSubArg(value){
return String(value||'').trim();
}
function _getSlashModelSubArgsFromDom(){
const sel=$('modelSelect');
if(!sel) return [];
const values=[];
for(const opt of Array.from(sel.options||[])){
const value=_normalizeSlashSubArg(opt.value||opt.textContent||'');
if(value) values.push(value);
}
return Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
}
async function _loadSlashModelSubArgs(force=false){
const domValues=_getSlashModelSubArgsFromDom();
if(domValues.length&&!force){
_slashModelCache=domValues;
return domValues;
}
if(_slashModelCache&&!force) return _slashModelCache;
if(_slashModelCachePromise&&!force) return _slashModelCachePromise;
_slashModelCachePromise=(async()=>{
try{
const data=await api('/api/models');
const values=[];
for(const group of (data&&data.groups)||[]){
for(const model of (group&&group.models)||[]){
const id=_normalizeSlashSubArg(model&&model.id);
if(id) values.push(id);
}
}
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
_slashModelCache=deduped;
return deduped;
}catch(_){
_slashModelCache=domValues;
return domValues;
}finally{
_slashModelCachePromise=null;
}
})();
return _slashModelCachePromise;
}
async function _loadSlashPersonalitySubArgs(force=false){
if(_slashPersonalityCache&&!force) return _slashPersonalityCache;
if(_slashPersonalityCachePromise&&!force) return _slashPersonalityCachePromise;
_slashPersonalityCachePromise=(async()=>{
try{
const data=await api('/api/personalities');
const values=['none'];
for(const p of (data&&data.personalities)||[]){
const name=_normalizeSlashSubArg(p&&p.name);
if(name) values.push(name);
}
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
_slashPersonalityCache=deduped;
return deduped;
}catch(_){
_slashPersonalityCache=['none'];
return _slashPersonalityCache;
}finally{
_slashPersonalityCachePromise=null;
}
})();
return _slashPersonalityCachePromise;
}
function _getSlashSubArgOptions(spec){
if(Array.isArray(spec)) return Promise.resolve(spec.slice());
if(spec==='models') return _loadSlashModelSubArgs();
if(spec==='personalities') return _loadSlashPersonalitySubArgs();
return Promise.resolve([]);
}
function _parseSlashAutocomplete(text){
if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null;
const raw=text.slice(1);
const hasSpace=/\s/.test(raw);
const parts=raw.split(/\s+/);
const cmdName=(parts[0]||'').toLowerCase();
const command=COMMANDS.find(c=>c.name===cmdName);
const subArgSource=(command&&command.subArgs)?command:SLASH_SUBARG_SOURCES[cmdName];
if(!hasSpace||!subArgSource){
return {kind:'commands', query:raw};
}
const argText=raw.slice(cmdName.length).replace(/^\s+/,'');
return {kind:'subargs', command:{name:cmdName, desc:subArgSource.desc, subArgs:subArgSource.subArgs}, query:argText.toLowerCase(), rawQuery:argText};
}
async function getSlashAutocompleteMatches(text){
const parsed=_parseSlashAutocomplete(text);
if(!parsed) return [];
if(parsed.kind==='commands') return getMatchingCommands(parsed.query);
const options=await _getSlashSubArgOptions(parsed.command.subArgs);
return options
.filter(opt=>String(opt).toLowerCase().startsWith(parsed.query))
.map(opt=>({
name:parsed.command.name,
value:String(opt),
desc:parsed.command.desc,
source:'subarg',
parent:parsed.command.name,
}));
}
function _compressionAnchorMessageKey(m){
if(!m||!m.role||m.role==='tool') return null;
let content='';
try{
content=typeof msgContent==='function' ? String(msgContent(m)||'') : String(m.content||'');
}catch(_){
content=String(m.content||'');
}
const norm=content.replace(/\s+/g,' ').trim().slice(0,160);
const ts=m._ts||m.timestamp||null;
const attachments=Array.isArray(m.attachments)?m.attachments.length:0;
if(!norm && !attachments && !ts) return null;
return {role:String(m.role||''), ts, text:norm, attachments};
}
// ── Command handlers ────────────────────────────────────────────────────────
function cmdHelp(){
const lines=COMMANDS.map(c=>{
const usage=c.arg?` <${c.arg}>`:'';
const usage=c.arg ? (String(c.arg).startsWith('[') ? ` ${c.arg}` : ` <${c.arg}>`) : '';
return ` /${c.name}${usage}${c.desc}`;
});
const msg={role:'assistant',content:t('available_commands')+'\n'+lines.join('\n')};
@@ -54,6 +220,7 @@ function cmdClear(){
if(!S.session)return;
S.messages=[];S.toolCalls=[];
clearLiveToolCards();
if(typeof clearCompressionUi==='function') clearCompressionUi();
renderMessages();
$('emptyState').style.display='';
showToast(t('conversation_cleared'));
@@ -92,19 +259,137 @@ async function cmdWorkspace(args){
}
async function cmdNew(){
if(typeof clearCompressionUi==='function') clearCompressionUi();
await newSession();
await renderSessionList();
$('msg').focus();
showToast(t('new_session'));
}
function cmdCompact(){
// Send as a regular message to the agent -- the agent's run_conversation
// preflight will detect the high token count and trigger _compress_context.
// We send a user message so it appears in the conversation.
$('msg').value='Please compress and summarize the conversation context to free up space.';
send();
showToast(t('compressing'));
async function _runManualCompression(focusTopic){
if(!S.session){showToast(t('no_active_session'));return;}
let visibleCount=0;
try{
const sid=S.session.session_id;
// Preflight: verify the viewed session still exists before compressing.
// This avoids a confusing "not found" toast when the UI is stale.
try{
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
if(!live||!live.session||live.session.session_id!==sid){
throw new Error('session no longer available');
}
S.session=live.session;
S.messages=live.session.messages||[];
S.toolCalls=live.session.tool_calls||[];
}catch(preflightErr){
if(typeof clearCompressionUi==='function') clearCompressionUi();
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
if(typeof setBusy==='function') setBusy(false);
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
showToast('Compression failed: '+(preflightErr.message||'session no longer available'));
return;
}
if(typeof setBusy==='function') setBusy(true);
const body={session_id:sid};
if(focusTopic) body.focus_topic=focusTopic;
const visibleMessages=(S.messages||[]).filter(m=>{
if(!m||!m.role||m.role==='tool') return false;
if(m.role==='assistant'){
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(hasTc||hasTu|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true;
}
return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length;
});
visibleCount=visibleMessages.length;
const anchorVisibleIdx=Math.max(0, visibleCount - 1);
const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null);
const commandText=focusTopic?`/compress ${focusTopic}`:'/compress';
if(typeof setCompressionUi==='function'){
setCompressionUi({
sessionId:S.session.session_id,
phase:'running',
focusTopic:focusTopic||'',
commandText,
beforeCount:visibleCount,
anchorVisibleIdx,
anchorMessageKey,
});
}
if(typeof setComposerStatus==='function') setComposerStatus(t('compressing'));
renderMessages();
const data=await api('/api/session/compress',{method:'POST',body:JSON.stringify(body)});
if(data&&data.session){
const currentSid=S.session&&S.session.session_id;
if(data.session.session_id&&data.session.session_id!==currentSid){
await loadSession(data.session.session_id);
}else{
S.session=data.session;
S.messages=data.session.messages||[];
S.toolCalls=data.session.tool_calls||[];
clearLiveToolCards();
localStorage.setItem('hermes-webui-session',S.session.session_id);
syncTopbar();
renderMessages();
await renderSessionList();
updateQueueBadge(S.session.session_id);
}
}
const summary=data&&data.summary;
if(typeof setCompressionUi==='function'&&S.session){
const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m));
const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'';
const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : '';
// Prefer the persisted compaction handoff when it already exists in session state.
// The short summary fallback is only for environments where that message is unavailable.
const referenceText=messageRef || summaryRef;
const effectiveFocus=(data&&data.focus_topic)||focusTopic||'';
setCompressionUi({
sessionId:S.session.session_id,
phase:'done',
focusTopic:effectiveFocus,
commandText:effectiveFocus?`/compress ${effectiveFocus}`:'/compress',
beforeCount:visibleCount,
summary:summary||null,
referenceText,
anchorVisibleIdx: data?.session?.compression_anchor_visible_idx,
anchorMessageKey: data?.session?.compression_anchor_message_key||null,
});
}
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
}catch(e){
if(typeof setCompressionUi==='function'){
const currentSid=S.session&&S.session.session_id;
setCompressionUi({
sessionId:currentSid||'',
phase:'error',
focusTopic:(focusTopic||'').trim(),
commandText:focusTopic?`/compress ${focusTopic}`:'/compress',
beforeCount:(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length,
errorText:`Compression failed: ${e.message}`,
anchorVisibleIdx: Math.max(0, visibleCount - 1),
anchorMessageKey:null,
});
}
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
if(typeof setBusy==='function') setBusy(false);
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
showToast('Compression failed: '+e.message);
return;
}
if(typeof setBusy==='function') setBusy(false);
}
async function cmdCompress(args){
await _runManualCompression((args||'').trim());
}
async function cmdCompact(args){
await _runManualCompression((args||'').trim());
}
async function cmdUsage(){
@@ -121,19 +406,48 @@ async function cmdUsage(){
}
async function cmdTheme(args){
const themes=['dark','light','slate','solarized','monokai','nord','oled'];
if(!args||!themes.includes(args.toLowerCase())){
showToast(t('theme_usage')+themes.join('|'));
const themes=['system','dark','light'];
const skins=(_SKINS||[]).map(s=>s.name.toLowerCase());
const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});
const val=(args||'').toLowerCase().trim();
// Check if it's a theme
if(themes.includes(val)||legacyThemes.includes(val)){
const appearance=_normalizeAppearance(
val,
legacyThemes.includes(val)?null:localStorage.getItem('hermes-skin')
);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
const sel=$('settingsTheme');
if(sel)sel.value=appearance.theme;
const skinSel=$('settingsSkin');
if(skinSel)skinSel.value=appearance.skin;
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
showToast(t('theme_set')+appearance.theme+(legacyThemes.includes(val)?` + ${appearance.skin}`:''));
return;
}
const themeName=args.toLowerCase();
document.documentElement.dataset.theme=themeName;
localStorage.setItem('hermes-theme',themeName);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:themeName})});}catch(e){}
// Update settings dropdown if panel is open
const sel=$('settingsTheme');
if(sel)sel.value=themeName;
showToast(t('theme_set')+themeName);
// Check if it's a skin
if(skins.includes(val)){
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),val);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
const sel=$('settingsSkin');
if(sel)sel.value=appearance.skin;
const themeSel=$('settingsTheme');
if(themeSel)themeSel.value=appearance.theme;
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
showToast(t('theme_set')+appearance.skin);
return;
}
showToast(t('theme_usage')+themes.join('|')+' | '+skins.join('|')+' | legacy:'+legacyThemes.join('|'));
}
async function cmdSkills(args){
@@ -205,10 +519,201 @@ async function cmdPersonality(args){
}
try{
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
S.messages.push({role:'assistant',content:t('personality_set')+`**${name}**`});
renderMessages();
showToast(t('personality_set')+name);
}catch(e){showToast(t('failed_colon')+e.message);}
}
async function cmdStop(){
if(!S.session){showToast(t('no_active_session'));return;}
if(!S.activeStreamId){showToast(t('no_active_task'));return;}
if(typeof cancelStream==='function'){await cancelStream();showToast(t('stream_stopped'));}
else showToast(t('cancel_unavailable'));
}
async function cmdTitle(args){
if(!S.session){showToast(t('no_active_session'));return;}
const name=(args||'').trim();
if(!name){
S.messages.push({role:'assistant',content:`${t('title_current')}: **${S.session.title||t('untitled')}**\n\n${t('title_change_hint')}`});
renderMessages();return;
}
try{
const r=await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,title:name})});
if(r&&r.error){showToast(r.error);return;}
S.session.title=(r&&r.session&&r.session.title)||name;
if(typeof syncTopbar==='function')syncTopbar();
if(typeof renderSessionList==='function')renderSessionList();
showToast(`${t('title_set')} "${S.session.title}"`);
S.messages.push({role:'assistant',content:`${t('title_set')} **${S.session.title}**`});
renderMessages();
}catch(e){showToast(t('failed_colon')+e.message);}
}
async function cmdRetry(){
if(!S.session){showToast(t('no_active_session'));return;}
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
const activeSid=S.session.session_id;
try{
const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();}
$('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();await send();
}catch(e){showToast(t('retry_failed')+e.message);}
}
async function cmdUndo(){
if(!S.session){showToast(t('no_active_session'));return;}
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
const activeSid=S.session.session_id;
try{
const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();}
showToast(`${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
}catch(e){showToast(t('undo_failed')+e.message);}
}
async function undoLastExchange(){await cmdUndo();}
async function cmdBtw(args){
if(!S.session){showToast(t('no_active_session'));return;}
const question=(args||'').trim();
if(!question){showToast(t('cmd_btw_usage'));return;}
showToast(t('btw_asking'));
const activeSid=S.session.session_id;
try{
const r=await api('/api/btw',{method:'POST',body:JSON.stringify({session_id:activeSid,question})});
if(r&&r.error){showToast(r.error);return;}
// Connect to the ephemeral SSE stream
const streamId=r.stream_id;
const parentSid=r.parent_session_id;
if(typeof attachBtwStream==='function') attachBtwStream(parentSid,streamId,question);
}catch(e){showToast(t('btw_failed')+e.message);}
}
async function cmdBackground(args){
if(!S.session){showToast(t('no_active_session'));return;}
const prompt=(args||'').trim();
if(!prompt){showToast(t('cmd_background_usage'));return;}
showToast(t('bg_running'));
const activeSid=S.session.session_id;
try{
const r=await api('/api/background',{method:'POST',body:JSON.stringify({session_id:activeSid,prompt})});
if(r&&r.error){showToast(r.error);return;}
// Show background badge and start polling
if(typeof showBackgroundBadge==='function') showBackgroundBadge(r.task_id);
if(typeof startBackgroundPolling==='function') startBackgroundPolling(activeSid,r.task_id,prompt);
}catch(e){showToast(t('bg_failed')+e.message);}
}
async function cmdStatus(){
if(!S.session){showToast(t('no_active_session'));return;}
try{
const r=await api('/api/session/status?session_id='+encodeURIComponent(S.session.session_id));
if(r&&r.error){showToast(r.error);return;}
S.messages.push({role:'assistant',content:[`**${t('status_heading')}**`,'',`**${t('status_session_id')}:** \`${r.session_id}\``,`**${t('status_title')}:** ${r.title||t('untitled')}`,`**${t('status_model')}:** ${r.model||t('usage_default_model')}`,`**${t('status_workspace')}:** ${r.workspace}`,`**${t('status_personality')}:** ${r.personality||t('usage_personality_none')}`,`**${t('status_messages')}:** ${r.message_count}`,`**${t('status_agent_running')}:** ${r.agent_running?t('status_yes'):t('status_no')}`,].join('\n')});
renderMessages();
}catch(e){showToast(t('status_load_failed')+e.message);}
}
function cmdReasoning(args){
const arg=(args||'').trim().toLowerCase();
const BRAIN='\uD83E\uDDE0';
// Matches hermes_constants.VALID_REASONING_EFFORTS + 'none' (CLI parity).
const EFFORTS=['none','minimal','low','medium','high','xhigh'];
// Shared status renderer used by the no-args branch and as a fallback.
function _fmtStatus(st){
const vis=(st && st.show_reasoning===false)?'off':'on';
const eff=(st && st.reasoning_effort)||'default';
return BRAIN+' Reasoning effort: '+eff+' \u00B7 display: '+vis
+' | /reasoning show|hide|none|minimal|low|medium|high|xhigh';
}
if(!arg){
// Status — read from the same config.yaml keys the CLI uses.
api('/api/reasoning').then(function(st){showToast(_fmtStatus(st));})
.catch(function(){showToast(BRAIN+' /reasoning — status unavailable');});
return true;
}
if(arg==='show'||arg==='on'||arg==='hide'||arg==='off'){
const on=(arg==='show'||arg==='on');
// Update the UI render gate immediately for responsiveness.
window._showThinking=on;
if(typeof renderMessages==='function') renderMessages();
// Persist via /api/reasoning → config.yaml display.show_reasoning
// (CLI reads the same key). Also mirror into WebUI settings.json
// show_thinking so boot.js picks it up on reload without hitting
// /api/reasoning on every page load.
api('/api/reasoning',{method:'POST',body:JSON.stringify({display:arg})}).catch(function(){});
api('/api/settings',{method:'POST',body:JSON.stringify({show_thinking:on})}).catch(function(){});
showToast(BRAIN+' Thinking blocks: '+(on?'on':'off')+' (saved)');
return true;
}
if(EFFORTS.includes(arg)){
// Persist via /api/reasoning → config.yaml agent.reasoning_effort.
// Takes effect on the NEXT session/turn (agent re-reads config at
// construction time), matching CLI semantics where `/reasoning high`
// also forces an agent re-init.
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})})
.then(function(st){
const eff=(st && st.reasoning_effort)||arg;
showToast(BRAIN+' Reasoning effort: '+eff+' (saved; applies to next turn)');
if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff);
})
.catch(function(e){
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));
});
return true;
}
showToast('Unknown argument: '+arg+' \u2014 use show|hide|'+EFFORTS.join('|'));
return true;
}
function cmdVoice(){
const mic=document.getElementById('btnMic');
if(mic&&mic.style.display!=='none'&&!mic.disabled){try{mic.click();return;}catch(_){}}
showToast(t('cmd_voice_use_mic'));
}
let _skillCommandCache=[];
let _skillCommandLoadPromise=null;
let _skillCommandCacheReady=false;
function _skillCommandSlug(name){
const raw=String(name||'').trim().toLowerCase();
if(!raw)return'';
return raw.replace(/[\s_]+/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-{2,}/g,'-').replace(/^-+|-+$/g,'');
}
function _buildSkillCommandEntry(skill){
const skillName=String(skill&&skill.name||'').trim();
const slug=_skillCommandSlug(skillName);
if(!slug)return null;
if(COMMANDS.some(c=>c.name===slug)) return null;
return{name:slug,desc:String(skill&&skill.description||'').trim()||t('slash_skill_desc'),source:'skill',skillName};
}
async function loadSkillCommands(force=false){
if(_skillCommandCacheReady&&!force)return _skillCommandCache;
if(_skillCommandLoadPromise&&!force)return _skillCommandLoadPromise;
_skillCommandLoadPromise=(async()=>{
try{
const data=await api('/api/skills');
const deduped=new Map();
for(const skill of (data&&data.skills)||[]){const entry=_buildSkillCommandEntry(skill);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);}
_skillCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name));
}catch(_){_skillCommandCache=[];}
finally{_skillCommandCacheReady=true;_skillCommandLoadPromise=null;}
return _skillCommandCache;
})();
return _skillCommandLoadPromise;
}
function refreshSlashCommandDropdown(){
const ta=$('msg');if(!ta)return;
const text=ta.value||'';
if(!text.startsWith('/')||text.indexOf('\n')!==-1){hideCmdDropdown();return;}
getSlashAutocompleteMatches(text).then(matches=>{
if(($('msg').value||'')!==text) return;
if(matches.length)showCmdDropdown(matches);else hideCmdDropdown();
});
}
function ensureSkillCommandsLoadedForAutocomplete(){
if(_skillCommandCacheReady||_skillCommandLoadPromise)return;
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
}
// ── Autocomplete dropdown ───────────────────────────────────────────────────
let _cmdSelectedIdx=-1;
@@ -217,19 +722,36 @@ function showCmdDropdown(matches){
const dd=$('cmdDropdown');
if(!dd)return;
dd.innerHTML='';
_cmdSelectedIdx=-1;
_cmdSelectedIdx=matches.length?0:-1;
for(let i=0;i<matches.length;i++){
const c=matches[i];
const el=document.createElement('div');
el.className='cmd-item';
if(i===_cmdSelectedIdx) el.classList.add('selected');
el.dataset.idx=i;
const usage=c.arg?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
el.innerHTML=`<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
const isSubArg=c.source==='subarg';
const usage=(!isSubArg&&c.arg)?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
const badge=c.source==='skill'?`<span class="cmd-item-badge cmd-item-badge-skill">${esc(t('slash_skill_badge'))}</span>`:'';
if(c.source==='skill') el.classList.add('cmd-item-skill');
const nameHtml=isSubArg
? `<div class="cmd-item-name"><span class="cmd-item-parent">/${esc(c.parent)}</span> <span class="cmd-item-subarg">${esc(c.value)}</span></div>`
: `<div class="cmd-item-name">/${esc(c.name)}${usage}${badge}</div>`;
const descHtml=`<div class="cmd-item-desc">${esc(c.desc)}</div>`;
el.innerHTML=`${nameHtml}${descHtml}`;
el.onmousedown=(e)=>{
e.preventDefault();
$('msg').value='/'+c.name+(c.arg?' ':'');
hideCmdDropdown();
const nextValue=isSubArg?('/'+c.parent+' '+c.value):('/'+c.name+(c.arg?' ':''));
$('msg').value=nextValue;
$('msg').focus();
if(!isSubArg&&c.source!=='skill'&&nextValue.endsWith(' ')&&typeof getSlashAutocompleteMatches==='function'){
getSlashAutocompleteMatches(nextValue).then(matches=>{
if(($('msg').value||'')!==nextValue) return;
if(matches.length) showCmdDropdown(matches);
else hideCmdDropdown();
});
}else{
hideCmdDropdown();
}
};
dd.appendChild(el);
}
@@ -252,6 +774,9 @@ function navigateCmdDropdown(dir){
if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1;
if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0;
items[_cmdSelectedIdx].classList.add('selected');
// Scroll the newly highlighted item into view so it stays visible when the
// dropdown overflows and the user navigates with keyboard (#838).
items[_cmdSelectedIdx].scrollIntoView({block:'nearest'});
}
function selectCmdDropdownItem(){
@@ -265,3 +790,9 @@ function selectCmdDropdownItem(){
}
hideCmdDropdown();
}
// ── Handler aliases (for test-discoverable command registration) ──────────────
// The COMMANDS array above is the authoritative dispatch table. These aliases
// allow tooling and tests to discover command handlers by name independently.
const HANDLERS = {};
HANDLERS.skills = cmdSkills;

BIN
static/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

20
static/favicon.svg Normal file
View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#1a1a1a"/>
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
</linearGradient>
</defs>
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#g)"/>
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42"
fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42"
fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ const LI_PATHS = {
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
'undo': '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>',
'check': '<polyline points="20 6 9 17 4 12"/>',
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',

View File

@@ -4,174 +4,204 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes</title>
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" type="image/svg+xml" href="static/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
<link rel="shortcut icon" href="static/favicon.ico">
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Hermes">
<link rel="apple-touch-icon" href="static/favicon.svg">
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<link rel="stylesheet" href="static/style.css">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
<!-- streaming-markdown: incremental DOM-building markdown parser for live streams -->
<!-- Self-hosted from npm:streaming-markdown@0.2.15 — no CDN dependency. -->
<!-- sha384 of smd.min.js @0.2.15: sha384-T6r95ocN9t3W8tUK2Fa6FPaO7bJryyjyW0WCalrUnpgtm2qXr5xcN4vwPYEJ6vHa -->
<!-- ES module imports do not support the integrity= attribute (W3C limitation); -->
<!-- version is pinned in the vendored file path; hash documented above for audit. -->
<script type="module">
import * as smd from '/static/vendor/smd.min.js';
// SRI verification happens at the ES module level via importmap or SW; pinning version in URL.
// sha384 of smd.min.js @0.2.15: sha384-T6r95ocN9t3W8tUK2Fa6FPaO7bJryyjyW0WCalrUnpgtm2qXr5xcN4vwPYEJ6vHa
window.smd = smd;
</script>
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
<link id="prism-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script>
<!-- PWA service worker registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('sw.js').catch(function(err) {
console.warn('[pwa] Service worker registration failed:', err);
});
});
}
</script>
</head>
<body>
<header class="app-titlebar" role="banner">
<button class="app-titlebar-hamburger" id="btnHamburger" onclick="toggleMobileSidebar()" type="button" title="Menu" aria-label="Menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="app-titlebar-inner">
<span class="app-titlebar-icon" aria-hidden="true">
<svg viewBox="0 0 64 64" width="16" height="16" aria-hidden="true">
<defs>
<linearGradient id="app-titlebar-gold" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F5C542"/>
<stop offset="100%" style="stop-color:#D4961C"/>
</linearGradient>
</defs>
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#app-titlebar-gold)"/>
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
</svg>
</span>
<span class="app-titlebar-title" id="appTitlebarTitle">Hermes</span>
<span class="app-titlebar-sub" id="appTitlebarSub" hidden></span>
<div class="tps-chip" id="tpsStat" title="Tokens per second / minute">0.0 t/s · 0.0 high</div>
</div>
<div class="app-titlebar-spacer" aria-hidden="true"></div>
</header>
<div class="layout">
<nav class="rail" aria-label="Primary navigation">
<button class="rail-btn nav-tab active" data-panel="chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat" aria-label="Chat"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="rail-btn nav-tab" data-panel="tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks" aria-label="Tasks"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="rail-btn nav-tab" data-panel="skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills" aria-label="Skills"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="rail-btn nav-tab" data-panel="memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory" aria-label="Memory"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/></svg></button>
<button class="rail-btn nav-tab" data-panel="workspaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces" aria-label="Spaces"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="rail-btn nav-tab" data-panel="profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles" aria-label="Agent profiles"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="rail-btn nav-tab" data-panel="todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos" aria-label="Todos"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<div class="rail-spacer"></div>
<button class="rail-btn nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</nav>
<aside class="sidebar">
<div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/></svg></button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/></svg></button>
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
<button class="nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</div>
<!-- Chat panel -->
<div class="panel-view active" id="panelChat">
<div class="sidebar-section">
<button class="new-chat-btn" id="btnNewChat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span data-i18n="new_conversation">New conversation</span> <span style="font-size:10px;opacity:.5;margin-left:4px">Cmd+K</span>
</button>
<div class="panel-head">
<span data-i18n="tab_chat">Chat</span>
<div class="panel-head-actions">
<button class="panel-head-btn" id="btnNewChat" title="New conversation (Cmd+K)" data-i18n-title="new_conversation" aria-label="New conversation">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
</div>
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()"></div>
<div class="session-search sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()" autocomplete="off"></div>
<div class="session-list" id="sessionList"></div>
</div>
<!-- Tasks (cron) panel -->
<div class="panel-view" id="panelTasks">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:11px;color:var(--muted)" data-i18n="scheduled_jobs">Scheduled jobs</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ <span data-i18n="new_job">New job</span></button>
</div>
<!-- Create job form (hidden by default) -->
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<input id="cronFormName" placeholder="Job name (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
<input id="cronFormSchedule" placeholder="Schedule: '0 9 * * *' or 'every 1h'" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
<textarea id="cronFormPrompt" rows="3" placeholder="Prompt (must be self-contained)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:6px"></textarea>
<select id="cronFormDeliver" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
<option value="local">Local (save output only)</option>
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
</select>
<div class="skill-picker-wrap" style="margin-bottom:8px">
<input id="cronFormSkillSearch" placeholder="Add skills (optional)..." style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none" autocomplete="off">
<div id="cronFormSkillDropdown" class="skill-picker-dropdown" style="display:none"></div>
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
<div class="panel-head">
<span data-i18n="scheduled_jobs">Scheduled jobs</span>
<div class="panel-head-actions">
<button class="panel-head-btn" id="cronRefreshBtn" onclick="loadCrons(true)" title="Refresh job list" aria-label="Refresh job list"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
<button class="panel-head-btn" onclick="openCronCreate()" title="New job" data-i18n-title="new_job" aria-label="New job"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
</div>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()" data-i18n="create_job">Create job</button>
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()" data-i18n="cancel">Cancel</button>
</div>
<div id="cronFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Skills panel -->
<div class="panel-view" id="panelSkills">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ <span data-i18n="new_skill">New skill</span></button>
</div>
<!-- Skill create/edit form (hidden by default) -->
<div id="skillCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<input id="skillFormName" placeholder="Skill name (e.g. my-skill)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<input id="skillFormCategory" placeholder="Category (optional, e.g. devops)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<textarea id="skillFormContent" rows="6" placeholder="SKILL.md content (YAML frontmatter + markdown body)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;margin-bottom:6px;box-sizing:border-box"></textarea>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()" data-i18n="save_skill">Save skill</button>
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()" data-i18n="cancel">Cancel</button>
<div class="panel-head">
<span data-i18n="tab_skills">Skills</span>
<div class="panel-head-actions">
<button class="panel-head-btn" onclick="openSkillCreate()" title="New skill" data-i18n-title="new_skill" aria-label="New skill"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
</div>
<div id="skillFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div class="skills-search sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Memory panel -->
<div class="panel-view" id="panelMemory">
<div style="padding:8px 12px 4px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<span style="font-size:11px;color:var(--muted)" data-i18n="personal_memory">Personal memory</span>
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> <span data-i18n="edit">Edit</span></button>
</div>
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
<!-- Memory edit form (hidden by default) -->
<div id="memoryEditForm" style="display:none;padding:8px 12px;border-top:1px solid var(--border);flex-shrink:0">
<div style="font-size:11px;color:var(--muted);margin-bottom:4px"><span data-i18n="editing">Editing</span>: <span id="memEditSection">memory</span></div>
<textarea id="memEditContent" rows="10" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:11px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;box-sizing:border-box;margin-bottom:6px;line-height:1.5"></textarea>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()" data-i18n="save">Save</button>
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()" data-i18n="cancel">Cancel</button>
</div>
<div id="memEditError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
<div class="panel-head">
<span data-i18n="personal_memory">Personal memory</span>
</div>
<div class="side-menu" id="memoryPanel"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Todo panel -->
<div class="panel-view" id="panelTodos">
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0" data-i18n="current_task_list">Current task list</div>
<div class="panel-head">
<span data-i18n="current_task_list">Current task list</span>
</div>
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
</div>
<!-- Workspaces panel -->
<div class="panel-view" id="panelWorkspaces">
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)" data-i18n="workspace_desc">Add and switch workspaces for your sessions.</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
<div class="panel-head">
<span data-i18n="tab_workspaces">Spaces</span>
<div class="panel-head-actions">
<button class="panel-head-btn" onclick="openWorkspaceCreate()" title="Add space" data-i18n-title="workspace_add_title" aria-label="Add space"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
</div>
</div>
<div class="panel-head-sub" data-i18n="workspace_desc">Add and switch workspaces for your sessions.</div>
<div style="flex:1;overflow-y:auto;padding:8px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Profiles panel -->
<div class="panel-view" id="panelProfiles">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:11px;color:var(--muted)" data-i18n="tab_profiles">Agent profiles</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleProfileForm()">+ <span data-i18n="new_profile">New profile</span></button>
</div>
<!-- Profile create form (hidden by default) -->
<div id="profileCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<input id="profileFormName" placeholder="Profile name (lowercase, a-z 0-9 hyphens)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);margin-bottom:8px;cursor:pointer">
<input type="checkbox" id="profileFormClone" style="accent-color:var(--accent)"> Clone config from active profile
</label>
<input id="profileFormBaseUrl" placeholder="Base URL (optional, e.g. http://localhost:11434)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<input id="profileFormApiKey" type="password" placeholder="API key (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitProfileCreate()">Create</button>
<button class="cron-btn" style="flex:1" onclick="toggleProfileForm()">Cancel</button>
<div class="panel-head">
<span data-i18n="tab_profiles">Agent profiles</span>
<div class="panel-head-actions">
<button class="panel-head-btn" onclick="openProfileCreate()" title="New profile" data-i18n-title="new_profile" aria-label="New profile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
</div>
<div id="profileFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="profilesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
<div style="flex:1;overflow-y:auto;padding:8px" id="profilesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<div class="sidebar-bottom">
<button class="hermes-launch-btn" id="btnHermesPanel" onclick="toggleSettings()" title="Open Hermes control center">
<span class="hermes-launch-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="hermes-gold-sidebar" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
</linearGradient>
</defs>
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#hermes-gold-sidebar)"/>
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42" fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42" fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
</svg></span>
<span class="hermes-launch-copy">
<span class="hermes-launch-title">Hermes WebUI</span>
<span class="hermes-launch-meta">Preferences, imports, exports</span>
</span>
<span class="hermes-launch-chevron" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
</button>
<!-- Settings panel (menu list; actual panes render in .main) -->
<div class="panel-view" id="panelSettings">
<div class="panel-head">
<span data-i18n="tab_settings">Settings</span>
</div>
<div class="side-menu" id="settingsMenu">
<button type="button" class="side-menu-item active" data-settings-section="conversation" onclick="switchSettingsSection('conversation')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>Conversation</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="appearance" onclick="switchSettingsSection('appearance')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<span>Appearance</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="preferences" onclick="switchSettingsSection('preferences')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
<span>Preferences</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="providers" onclick="switchSettingsSection('providers')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
<span data-i18n="providers_tab_title">Providers</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="system" onclick="switchSettingsSection('system')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
<span>System</span>
</button>
</div>
</div>
<div class="resize-handle" id="sidebarResize"></div>
</aside>
<main class="main">
<div class="topbar">
<button class="mobile-hamburger" id="btnHamburger" onclick="toggleMobileSidebar()" title="Menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta" data-i18n="new_conversation">Start a new conversation</div></div>
<div class="topbar-chips">
<button class="chip workspace-toggle-btn" id="btnWorkspacePanelToggle" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg><span class="workspace-toggle-label">Files</span></button>
</div>
</div>
<div id="mainChat" class="main-view">
<div class="messages" id="messages">
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" aria-label="Scroll to bottom" onclick="scrollToBottom()" style="display:none"></button>
<div class="empty-state" id="emptyState">
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus">
<defs>
@@ -199,13 +229,18 @@
</div>
</div>
<div class="messages-inner" id="msgInner"></div>
<div id="liveCompressionCards" class="live-compression-cards"></div>
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
</div>
<div class="update-banner" id="updateBanner">
<span id="updateMsg"></span>
<div style="display:flex;gap:8px;flex-shrink:0">
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
<span id="updateMsg"></span>
<div id="updateError" style="display:none;font-size:12px;color:var(--error,#e05);margin-top:4px;word-break:break-word"></div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
<button class="update-btn" onclick="dismissUpdate()">Later</button>
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()">Update Now</button>
<button class="update-btn" id="btnForceUpdate" style="display:none;background:var(--error,#e05);color:#fff;border-color:var(--error,#e05)" onclick="forceUpdate(this)">Force update</button>
</div>
</div>
<div class="reconnect-banner" id="reconnectBanner">
@@ -215,38 +250,64 @@
<button class="reconnect-btn" onclick="refreshSession()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Reload</button>
</div>
</div>
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
<div class="approval-inner">
<div class="approval-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-btns">
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
<kbd class="approval-kbd"></kbd>
</button>
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
</button>
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
</button>
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
</button>
<div class="composer-wrap" id="composerWrap">
<div class="composer-flyout">
<!-- Queue flyout: slides up from behind composer, same pattern as approval-card -->
<div id="queueCard" class="queue-card" role="region" aria-label="Queued messages" aria-live="polite">
<div id="queueChips" class="queue-card-inner"></div>
</div>
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
<div class="approval-inner">
<div class="approval-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
<div class="approval-btns">
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
<kbd class="approval-kbd"></kbd>
</button>
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
</button>
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
</button>
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
</button>
</div>
</div>
</div>
</div>
<div class="composer-wrap" id="composerWrap">
<div class="cmd-dropdown" id="cmdDropdown"></div>
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
<div class="clarify-inner">
<div class="clarify-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
</div>
<div class="clarify-question" id="clarifyQuestion"></div>
<div class="clarify-choices" id="clarifyChoices"></div>
<div class="clarify-response">
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
</div>
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
</div>
</div>
</div>
<!-- Queue pill outer: same positioning wrapper as .queue-card (max-width + padding) -->
<div class="queue-pill-outer">
<button id="queuePill" class="queue-pill" aria-label="Show queued messages" type="button"></button>
</div>
<div class="composer-box" id="composerBox">
<div class="cmd-dropdown" id="cmdDropdown"></div>
<div class="drop-hint" id="dropHint">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Drop files to upload to workspace
@@ -256,7 +317,7 @@
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<div class="composer-footer">
<div class="composer-left">
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env" style="display:none">
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env,.xls,.xlsx,.doc,.docx" style="display:none">
<button class="icon-btn" id="btnAttach" title="Attach files">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
@@ -277,16 +338,20 @@
</button>
</div>
<div class="composer-ws-wrap">
<button class="composer-workspace-chip ws-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" disabled>
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<span class="composer-workspace-label" id="composerWorkspaceLabel">Workspace</span>
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="composer-workspace-group ws-chip" id="composerWorkspaceGroup" role="group" aria-label="Workspace controls">
<button class="composer-workspace-files-btn" id="btnWorkspacePanelToggle" type="button" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false" aria-label="Toggle workspace files panel">
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
</button>
<button class="composer-workspace-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" disabled>
<span class="composer-workspace-label" id="composerWorkspaceLabel"></span>
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
</div>
</div>
<div class="composer-model-wrap">
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
<span class="composer-model-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
<span class="composer-model-label" id="composerModelLabel">Model</span>
<span class="composer-model-label" id="composerModelLabel"></span>
<span class="composer-model-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<select id="modelSelect" class="composer-model-select" title="Conversation model" aria-hidden="true" tabindex="-1">
@@ -302,12 +367,20 @@
<option value="anthropic/claude-haiku-3-5">Claude Haiku 3.5</option>
</optgroup>
<optgroup label="Other">
<option value="google/gemini-2.5-pro">Gemini 2.5 Pro</option>
<option value="google/gemini-3.1-pro-preview">Gemini 3.1 Pro Preview</option>
<option value="google/gemini-3-flash-preview">Gemini 3 Flash Preview</option>
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek V3</option>
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
</optgroup>
</select>
</div>
<div class="composer-reasoning-wrap" id="composerReasoningWrap" style="display:none">
<button class="composer-reasoning-chip" id="composerReasoningChip" type="button" onclick="toggleReasoningDropdown()" title="Reasoning effort level">
<span class="composer-reasoning-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.46 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.46 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/></svg></span>
<span class="composer-reasoning-label" id="composerReasoningLabel"></span>
<span class="composer-reasoning-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
</div>
</div>
<div class="composer-right">
<span class="composer-status" id="composerStatus" style="display:none"></span>
@@ -332,101 +405,122 @@
<button class="cancel-btn" id="btnCancel" onclick="cancelStream()" style="display:none" title="Stop generation" aria-label="Stop generation">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>
</button>
<span class="bg-badge" id="bgBadge" style="display:none" title="Background tasks running">0</span>
<button class="send-btn" id="btnSend" title="Send message" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button>
</div>
<div class="profile-dropdown" id="profileDropdown"></div>
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown"></div>
<div class="composer-reasoning-dropdown" id="composerReasoningDropdown">
<div class="reasoning-option" data-effort="none">None</div>
<div class="reasoning-option" data-effort="minimal">Minimal</div>
<div class="reasoning-option" data-effort="low">Low</div>
<div class="reasoning-option" data-effort="medium">Medium</div>
<div class="reasoning-option" data-effort="high">High</div>
<div class="reasoning-option" data-effort="xhigh">Extra High</div>
</div>
<div class="model-dropdown" id="composerModelDropdown"></div>
</div>
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
</div>
</div>
</main>
<aside class="rightpanel">
<div class="resize-handle" id="rightpanelResize"></div>
<div class="panel-header">
<span>Workspace</span>
<span class="git-badge" id="gitBadge" style="display:none"></span>
<div class="panel-actions">
<button class="panel-icon-btn" id="btnCollapseWorkspacePanel" title="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button class="panel-icon-btn mobile-close-btn" onclick="closeWorkspacePanel()" title="Close" aria-label="Close workspace panel">×</button>
</div>
</div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
<div class="file-tree" id="fileTree"></div>
<div class="preview-area" id="previewArea">
<div class="preview-path" id="previewPath">
<span id="previewPathText"></span>
<span class="preview-badge" id="previewBadge"></span>
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:inline-flex;align-items:center;gap:4px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download</button>
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="toggleEditMode()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> Edit</button>
</div>
<pre class="preview-code" id="previewCode"></pre>
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
<div class="preview-md" id="previewMd" style="display:none"></div>
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:#e2e8f0;border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
</div>
</aside>
</div>
<div class="onboarding-overlay" id="onboardingOverlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
<div class="onboarding-card">
<div class="onboarding-shell">
<div class="onboarding-sidebar">
<div class="onboarding-badge" data-i18n="onboarding_badge">FIRST RUN</div>
<h2 id="onboardingTitle" data-i18n="onboarding_title">Welcome to Hermes Web UI</h2>
<p id="onboardingLead" data-i18n="onboarding_lead">A quick guided setup will check your Hermes install, choose a workspace and model, and optionally protect the app with a password.</p>
<div class="onboarding-steps" id="onboardingSteps"></div>
</div>
<div class="onboarding-main">
<div class="onboarding-status" id="onboardingNotice"></div>
<div class="onboarding-body" id="onboardingBody"></div>
<div class="onboarding-actions">
<button class="sm-btn" id="onboardingBackBtn" onclick="prevOnboardingStep()" style="display:none" data-i18n="onboarding_back">Back</button>
<button class="sm-btn" id="onboardingNextBtn" onclick="nextOnboardingStep()" style="font-weight:700;color:var(--blue);border-color:rgba(124,185,255,.32)" data-i18n="onboarding_continue">Continue</button>
</div><!-- /#mainChat -->
<div id="mainSkills" class="main-view">
<div class="main-view-header">
<div class="main-view-title" id="skillDetailTitle"></div>
<div class="main-view-actions">
<button id="btnEditSkillDetail" class="panel-head-btn" title="Edit" data-i18n-title="skills_edit" onclick="editCurrentSkill()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
<button id="btnDeleteSkillDetail" class="panel-head-btn" title="Delete" data-i18n-title="skills_delete" onclick="deleteCurrentSkill()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
<button id="btnCancelSkillDetail" class="panel-head-btn" title="Cancel" data-i18n-title="cancel" onclick="cancelSkillForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button id="btnSaveSkillDetail" class="panel-head-btn primary" title="Save" data-i18n-title="save" onclick="saveSkillForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
</div>
</div>
</div>
</div>
</div>
<div class="settings-overlay" id="settingsOverlay" style="display:none">
<div class="settings-panel">
<div class="settings-header">
<div class="settings-heading">
<div class="settings-kicker">Hermes WebUI</div>
<h3 style="margin:0;font-size:18px">Control Center</h3>
<div class="settings-subtitle">Preferences, conversation tools, and system controls.</div>
<div class="main-view-body" id="skillDetailBody" style="display:none"></div>
<div class="main-view-empty" id="skillDetailEmpty">
<svg class="main-view-empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<div class="main-view-empty-title" data-i18n="skills_empty_title">Select a skill</div>
<div class="main-view-empty-sub" data-i18n="skills_empty_sub">Pick a skill from the sidebar to view its contents, or create a new one.</div>
</div>
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="settings-body">
<div class="settings-shell">
<div class="settings-tabs" role="tablist" aria-label="Hermes control center sections">
<button class="settings-tab active" id="settingsTabConversation" type="button" role="tab" aria-selected="true" aria-controls="settingsPaneConversation" onclick="switchSettingsSection('conversation')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span class="settings-tab-title">Conversation</span>
</button>
<button class="settings-tab" id="settingsTabPreferences" type="button" role="tab" aria-selected="false" aria-controls="settingsPanePreferences" onclick="switchSettingsSection('preferences')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
<span class="settings-tab-title">Preferences</span>
</button>
<button class="settings-tab" id="settingsTabSystem" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneSystem" onclick="switchSettingsSection('system')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
<span class="settings-tab-title">System</span>
</button>
<div id="mainMemory" class="main-view">
<div class="main-view-header">
<div class="main-view-title" id="memoryDetailTitle"></div>
<div class="main-view-actions">
<button id="btnEditMemoryDetail" class="panel-head-btn" title="Edit" aria-label="Edit" data-i18n-title="edit" onclick="editCurrentMemory()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
<button id="btnCancelMemoryDetail" class="panel-head-btn" title="Cancel" data-i18n-title="cancel" onclick="cancelMemoryEdit()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button id="btnSaveMemoryDetail" class="panel-head-btn primary" title="Save" data-i18n-title="save" onclick="submitMemorySave()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
</div>
<div class="settings-main">
<div class="settings-pane active" id="settingsPaneConversation" role="tabpanel" aria-labelledby="settingsTabConversation">
</div>
<div class="main-view-body" id="memoryDetailBody" style="display:none"></div>
<div class="main-view-empty" id="memoryDetailEmpty">
<svg class="main-view-empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/></svg>
<div class="main-view-empty-title" data-i18n="memory_empty_title">Select a memory section</div>
<div class="main-view-empty-sub" data-i18n="memory_empty_sub">Pick a section from the sidebar to view or edit its contents.</div>
</div>
</div>
<div id="mainTasks" class="main-view">
<div class="main-view-header">
<div class="main-view-title" id="taskDetailTitle"></div>
<div class="main-view-actions">
<button id="btnRunTaskDetail" class="panel-head-btn" title="Run now" data-i18n-title="cron_run_now" onclick="runCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
<button id="btnPauseTaskDetail" class="panel-head-btn" title="Pause" data-i18n-title="cron_pause" onclick="pauseCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg></button>
<button id="btnResumeTaskDetail" class="panel-head-btn" title="Resume" data-i18n-title="cron_resume" onclick="resumeCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/><line x1="22" y1="4" x2="22" y2="20"/></svg></button>
<button id="btnEditTaskDetail" class="panel-head-btn" title="Edit" data-i18n-title="edit" onclick="editCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
<button id="btnDeleteTaskDetail" class="panel-head-btn" title="Delete" data-i18n-title="delete_title" onclick="deleteCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
<button id="btnCancelTaskDetail" class="panel-head-btn" title="Cancel" data-i18n-title="cancel" onclick="cancelCronForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button id="btnSaveTaskDetail" class="panel-head-btn primary" title="Save" data-i18n-title="save" onclick="saveCronForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
</div>
</div>
<div class="main-view-body" id="taskDetailBody" style="display:none"></div>
<div class="main-view-empty" id="taskDetailEmpty">
<svg class="main-view-empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<div class="main-view-empty-title" data-i18n="tasks_empty_title">Select a scheduled job</div>
<div class="main-view-empty-sub" data-i18n="tasks_empty_sub">Pick a job from the sidebar to view its details and runs, or create a new one.</div>
</div>
</div>
<div id="mainWorkspaces" class="main-view">
<div class="main-view-header">
<div class="main-view-title" id="workspaceDetailTitle"></div>
<div class="main-view-actions">
<button id="btnActivateWorkspaceDetail" class="panel-head-btn" title="Use this space" data-i18n-title="workspace_use_title" onclick="activateCurrentWorkspace()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
<button id="btnEditWorkspaceDetail" class="panel-head-btn" title="Rename" data-i18n-title="edit" onclick="editCurrentWorkspace()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
<button id="btnDeleteWorkspaceDetail" class="panel-head-btn" title="Remove" data-i18n-title="remove" onclick="deleteCurrentWorkspace()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
<button id="btnCancelWorkspaceDetail" class="panel-head-btn" title="Cancel" data-i18n-title="cancel" onclick="cancelWorkspaceForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button id="btnSaveWorkspaceDetail" class="panel-head-btn primary" title="Save" data-i18n-title="save" onclick="saveWorkspaceForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
</div>
</div>
<div class="main-view-body" id="workspaceDetailBody" style="display:none"></div>
<div class="main-view-empty" id="workspaceDetailEmpty">
<svg class="main-view-empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<div class="main-view-empty-title" data-i18n="workspaces_empty_title">Select a space</div>
<div class="main-view-empty-sub" data-i18n="workspaces_empty_sub">Pick a space from the sidebar to view its files and settings, or add a new one.</div>
</div>
</div>
<div id="mainProfiles" class="main-view">
<div class="main-view-header">
<div class="main-view-title" id="profileDetailTitle"></div>
<div class="main-view-actions">
<button id="btnActivateProfileDetail" class="panel-head-btn" title="Activate" data-i18n-title="profile_switch_title" onclick="activateCurrentProfile()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
<button id="btnDeleteProfileDetail" class="panel-head-btn" title="Delete" data-i18n-title="profile_delete_title" onclick="deleteCurrentProfile()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
<button id="btnCancelProfileDetail" class="panel-head-btn" title="Cancel" data-i18n-title="cancel" onclick="cancelProfileForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button id="btnSaveProfileDetail" class="panel-head-btn primary" title="Save" data-i18n-title="save" onclick="saveProfileForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
</div>
</div>
<div class="main-view-body" id="profileDetailBody" style="display:none"></div>
<div class="main-view-empty" id="profileDetailEmpty">
<svg class="main-view-empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<div class="main-view-empty-title" data-i18n="profiles_empty_title">Select a profile</div>
<div class="main-view-empty-sub" data-i18n="profiles_empty_sub">Pick an agent profile from the sidebar to view and edit its settings, or create a new one.</div>
</div>
</div>
<div id="mainSettings" class="main-view">
<div class="settings-main">
<div class="settings-pane active" id="settingsPaneConversation">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Conversation</div>
<div class="settings-section-meta" id="hermesSessionMeta">No active conversation selected.</div>
<div class="settings-section-title" data-i18n="settings_section_conversation_title">Conversation</div>
<div class="settings-section-meta" id="hermesSessionMeta" data-i18n="active_conversation_none">No active conversation selected.</div>
</div>
</div>
<div class="hermes-action-grid">
@@ -437,11 +531,81 @@
</div>
<input type="file" id="importFileInput" accept=".json" style="display:none">
</div>
<div class="settings-pane" id="settingsPanePreferences" role="tabpanel" aria-labelledby="settingsTabPreferences">
<div class="settings-pane" id="settingsPaneAppearance">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Preferences</div>
<div class="settings-section-meta">Defaults and UI behavior for Hermes Web UI.</div>
<div class="settings-section-title" data-i18n="settings_section_appearance_title">Appearance</div>
<div class="settings-section-meta" data-i18n="settings_section_appearance_meta">Theme, accent colors, and visual style.</div>
</div>
</div>
<div class="settings-field">
<label data-i18n="settings_label_theme">Theme</label>
<div id="themePickerGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:4px">
<button type="button" data-theme-val="light" onclick="_pickTheme('light')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:#fff;border:1px solid rgba(0,0,0,.12);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" fill="none" stroke="#999" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)">Light</span>
</button>
<button type="button" data-theme-val="dark" onclick="_pickTheme('dark')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:#1a1a2e;border:1px solid rgba(255,255,255,.1);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" fill="none" stroke="#666" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"/></svg>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)">Dark</span>
</button>
<button type="button" data-theme-val="system" onclick="_pickTheme('system')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:linear-gradient(to right,#fff,#1a1a2e);border:1px solid rgba(0,0,0,.12);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" fill="none" stroke="#888" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)">System</span>
</button>
</div>
<input type="hidden" id="settingsTheme" value="dark">
</div>
<div class="settings-field">
<label data-i18n="settings_label_skin">Skin</label>
<div id="skinPickerGrid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:4px">
</div>
<input type="hidden" id="settingsSkin" value="default">
</div>
<div class="settings-field">
<label data-i18n="settings_label_font_size">Font size</label>
<div id="fontSizePickerGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:4px">
<button type="button" data-font-size-val="small" onclick="_pickFontSize('small')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<span style="font-size:10px;font-weight:600;color:var(--muted)">Aa</span>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_small">Small</span>
</button>
<button type="button" data-font-size-val="default" onclick="_pickFontSize('default')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<span style="font-size:13px;font-weight:600;color:var(--muted)">Aa</span>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_default">Default</span>
</button>
<button type="button" data-font-size-val="large" onclick="_pickFontSize('large')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<span style="font-size:17px;font-weight:600;color:var(--muted)">Aa</span>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_large">Large</span>
</button>
</div>
<input type="hidden" id="settingsFontSize" value="default">
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsWorkspacePanelOpen" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_workspace_panel_open">Keep workspace panel open by default</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_workspace_panel_open">When enabled, the workspace / file browser panel opens automatically with each new session. You can still close it manually at any time.</div>
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
</div>
<div class="settings-pane" id="settingsPanePreferences">
<div class="settings-section-head">
<div>
<div class="settings-section-title" data-i18n="settings_section_preferences_title">Preferences</div>
<div class="settings-section-meta" data-i18n="settings_section_preferences_meta">Defaults and UI behavior for Hermes Web UI.</div>
</div>
</div>
<div class="settings-field">
@@ -455,18 +619,6 @@
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select>
</div>
<div class="settings-field">
<label for="settingsTheme" data-i18n="settings_label_theme">Theme</label>
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="document.documentElement.dataset.theme=this.value;localStorage.setItem('hermes-theme',this.value)">
<option value="dark">Dark (default)</option>
<option value="light">Light</option>
<option value="slate">Slate (charcoal)</option>
<option value="solarized">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="nord">Nord</option>
<option value="oled">OLED</option>
</select>
</div>
<div class="settings-field">
<label for="settingsLanguage" data-i18n="settings_label_language">Language</label>
<select id="settingsLanguage" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
@@ -492,6 +644,14 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
</div>
<div class="settings-field">
<label for="settingsSidebarDensity" data-i18n="settings_label_sidebar_density">Sidebar density</label>
<select id="settingsSidebarDensity" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="compact" data-i18n="settings_sidebar_density_compact">Compact</option>
<option value="detailed" data-i18n="settings_sidebar_density_detailed">Detailed</option>
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sidebar_density">Controls how much metadata the session list shows in the left sidebar.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
@@ -520,15 +680,33 @@
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
</div>
<div class="settings-pane" id="settingsPaneSystem" role="tabpanel" aria-labelledby="settingsTabSystem">
<div class="settings-pane" id="settingsPaneProviders">
<div class="settings-section-head">
<div>
<div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div>
<div class="settings-section-title" data-i18n="providers_section_title">Providers</div>
<div class="settings-section-meta" data-i18n="providers_section_meta">Manage API keys for AI providers. Changes take effect immediately.</div>
</div>
<span class="settings-version-badge">v0.50.12</span>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<div id="providersList" style="display:flex;flex-direction:column;margin-top:4px">
<!-- Populated dynamically by loadProvidersPanel() -->
</div>
<div id="providersEmpty" style="display:none;text-align:center;padding:32px 0;color:var(--muted);font-size:13px" data-i18n="providers_empty">
No configurable providers found.
</div>
</div>
<div class="settings-pane" id="settingsPaneSystem">
<div class="settings-section-head">
<div>
<div class="settings-section-title" data-i18n="settings_section_system_title">System</div>
<div class="settings-section-meta" data-i18n="settings_section_system_meta">Instance version and access controls.</div>
</div>
<div id="checkUpdatesBlock">
<span class="settings-version-badge"></span>
<button class="btn-tiny" id="btnCheckUpdatesNow" onclick="checkUpdatesNow()" title="Check for updates now" data-i18n-title="settings_check_now"><svg id="checkUpdatesSpinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="spinner-xs" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56"/><polyline points="21 3 21 9 15 9"/></svg><span id="checkUpdatesLabel" data-i18n="settings_check_now">Check now</span></button>
<span id="checkUpdatesStatus"></span>
</div>
</div>
<div class="settings-field">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
@@ -536,39 +714,67 @@
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
</div>
</div>
</div>
</div>
</main>
<aside class="rightpanel">
<div class="resize-handle" id="rightpanelResize"></div>
<div class="panel-header">
<span>Workspace</span>
<span class="git-badge" id="gitBadge" style="display:none"></span>
<div class="panel-actions">
<button class="panel-icon-btn" id="btnCollapseWorkspacePanel" title="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button class="panel-icon-btn mobile-close-btn" onclick="handleWorkspaceClose()" title="Close" aria-label="Close workspace panel">×</button>
</div>
</div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
<div class="file-tree" id="fileTree"></div>
<div id="wsEmptyState" style="display:none;flex:1;align-items:center;justify-content:center;padding:24px 16px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6"></div>
<div class="preview-area" id="previewArea">
<div class="preview-path" id="previewPath">
<span id="previewPathText"></span>
<span class="preview-badge" id="previewBadge"></span>
<button id="btnOpenInBrowser" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="openInBrowser()" title="Open in new browser tab"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg> <span data-i18n="open_in_browser">Open in browser</span></button>
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:inline-flex;align-items:center;gap:4px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download</button>
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="toggleEditMode()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> Edit</button>
</div>
<pre class="preview-code" id="previewCode"></pre>
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
<div class="preview-md" id="previewMd" style="display:none"></div>
<div class="preview-html-wrap" id="previewHtmlWrap" style="display:none;flex:1;border-radius:8px;overflow:hidden;border:1px solid var(--border2)">
<iframe id="previewHtmlIframe" style="width:100%;height:100%;border:none;background:#fff" sandbox="allow-scripts" title="HTML preview"></iframe>
</div>
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:var(--pre-text);border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
</div>
</aside>
</div>
<div class="onboarding-overlay" id="onboardingOverlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
<div class="onboarding-card">
<div class="onboarding-shell">
<div class="onboarding-sidebar">
<div class="onboarding-badge" data-i18n="onboarding_badge">FIRST RUN</div>
<h2 id="onboardingTitle" data-i18n="onboarding_title">Welcome to Hermes Web UI</h2>
<p id="onboardingLead" data-i18n="onboarding_lead">A quick guided setup will check your Hermes install, choose a workspace and model, and optionally protect the app with a password.</p>
<div class="onboarding-steps" id="onboardingSteps"></div>
</div>
<div class="onboarding-main">
<div class="onboarding-status" id="onboardingNotice"></div>
<div class="onboarding-body" id="onboardingBody"></div>
<div class="onboarding-actions">
<button class="sm-btn" id="onboardingBackBtn" onclick="prevOnboardingStep()" style="display:none" data-i18n="onboarding_back">Back</button>
<button class="sm-btn" id="onboardingSkipBtn" onclick="skipOnboarding()" style="margin-right:auto;opacity:.7" data-i18n="onboarding_skip">Skip setup</button>
<button class="sm-btn" id="onboardingNextBtn" onclick="nextOnboardingStep()" style="font-weight:700;color:var(--blue);border-color:rgba(124,185,255,.32)" data-i18n="onboarding_continue">Continue</button>
</div>
</div>
</div>
</div>
</div>
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar()"></div>
<nav class="mobile-bottom-nav" id="mobileBottomNav">
<button class="mobile-nav-btn active" data-panel="chat" onclick="mobileSwitchPanel('chat')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span data-i18n="tab_chat">Chat</span>
</button>
<button class="mobile-nav-btn" data-panel="tasks" onclick="mobileSwitchPanel('tasks')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span data-i18n="tab_tasks">Tasks</span>
</button>
<button class="mobile-nav-btn" data-panel="skills" onclick="mobileSwitchPanel('skills')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<span data-i18n="tab_skills">Skills</span>
</button>
<button class="mobile-nav-btn" data-panel="memory" onclick="mobileSwitchPanel('memory')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/></svg>
<span data-i18n="tab_memory">Memory</span>
</button>
<button class="mobile-nav-btn" data-panel="workspaces" onclick="mobileSwitchPanel('workspaces')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h8l2 2h10v14H2z"/></svg>
<span data-i18n="tab_workspaces">Spaces</span>
</button>
<button class="mobile-nav-btn" data-panel="profiles" onclick="mobileSwitchPanel('profiles')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span data-i18n="tab_profiles">Profiles</span>
</button>
</nav>
<div class="app-dialog-overlay" id="appDialogOverlay" style="display:none" aria-hidden="true">
<div class="app-dialog" id="appDialog" role="dialog" aria-modal="true" aria-labelledby="appDialogTitle" aria-describedby="appDialogDesc">
<div class="app-dialog-header">
@@ -586,15 +792,15 @@
</div>
</div>
<div class="toast" id="toast"></div>
<script src="/static/i18n.js"></script>
<script src="/static/icons.js"></script>
<script src="/static/ui.js"></script>
<script src="/static/workspace.js"></script>
<script src="/static/sessions.js"></script>
<script src="/static/commands.js"></script>
<script src="/static/messages.js"></script>
<script src="/static/panels.js"></script>
<script src="/static/onboarding.js"></script>
<script src="/static/boot.js"></script>
<script src="static/i18n.js" defer></script>
<script src="static/icons.js" defer></script>
<script src="static/ui.js" defer></script>
<script src="static/workspace.js" defer></script>
<script src="static/sessions.js" defer></script>
<script src="static/commands.js" defer></script>
<script src="static/messages.js" defer></script>
<script src="static/panels.js" defer></script>
<script src="static/onboarding.js" defer></script>
<script src="static/boot.js" defer></script>
</body>
</html>

View File

@@ -21,12 +21,26 @@ document.addEventListener('DOMContentLoaded', function () {
if (err) { err.style.display = 'none'; }
}
// Return the ?next= redirect path if present and safe, otherwise './'
// Guards against open-redirect: rejects protocol-relative (//evil.com),
// absolute URLs, backslash variants, and control characters.
function _safeNextPath() {
try {
var raw = new URL(window.location.href).searchParams.get('next');
if (!raw) return './';
if (raw.charAt(0) !== '/') return './'; // must be path-absolute
if (raw.charAt(1) === '/' || raw.charAt(1) === '\\') return './'; // reject // and \\
if (/[\x00-\x1f\x7f\s]/.test(raw)) return './'; // reject control chars / whitespace
return raw;
} catch (_) { return './'; }
}
async function doLogin(e) {
e.preventDefault();
var pw = input.value;
hideErr();
try {
var res = await fetch('/api/auth/login', {
var res = await fetch('api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
@@ -35,7 +49,7 @@ document.addEventListener('DOMContentLoaded', function () {
var data = {};
try { data = await res.json(); } catch (_) {}
if (res.ok && data.ok) {
window.location.href = '/';
window.location.href = _safeNextPath();
} else {
showErr(data.error || invalidPw);
}

23
static/manifest.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "Hermes",
"short_name": "Hermes",
"description": "Hermes AI Agent Web UI",
"start_url": "./",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#1a1a1a",
"orientation": "portrait-primary",
"icons": [
{
"src": "static/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "static/favicon-32.png",
"sizes": "32x32",
"type": "image/png"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,30 @@ function _getOnboardingSetupProvider(id){
return _getOnboardingSetupProviders().find(p=>p.id===id)||null;
}
function _getOnboardingSetupCategories(){
return (((ONBOARDING.status||{}).setup||{}).categories)||[];
}
/** Render the provider <select> with <optgroup> per category. */
function _renderProviderSelectOptions(selectedId){
const providers=_getOnboardingSetupProviders();
const categories=_getOnboardingSetupCategories();
const provMap={};
providers.forEach(p=>{provMap[p.id]=p;});
if(!categories.length){
// Fallback: flat list when no categories are available.
return providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
}
return categories.map(cat=>{
const opts=cat.providers.map(pid=>{
const p=provMap[pid];
if(!p)return '';
return `<option value="${esc(p.id)}"${p.id===selectedId?' selected':''}>${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`;
}).join('');
return `<optgroup label="${esc(t('provider_category_'+cat.id)||cat.label)}">${opts}</optgroup>`;
}).join('');
}
function _getOnboardingCurrentSetup(){
return (((ONBOARDING.status||{}).setup||{}).current)||{};
}
@@ -107,9 +131,9 @@ function _renderOnboardingBody(){
}
if(key==='setup'){
const providers=_getOnboardingSetupProviders();
const options=providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[0]||null;
const selectedId=ONBOARDING.form.provider;
const groupedOptions=_renderProviderSelectOptions(selectedId);
const provider=_getOnboardingSetupProvider(selectedId)||_getOnboardingSetupProviders()[0]||null;
const showBaseUrl=provider&&provider.requires_base_url;
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
@@ -132,7 +156,7 @@ function _renderOnboardingBody(){
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
</label>
<label class="onboarding-field" id="onboardingApiKeyField">
<span>${t('onboarding_api_key_label')}</span>
@@ -153,7 +177,7 @@ function _renderOnboardingBody(){
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
</label>
<label class="onboarding-field" id="onboardingApiKeyField">
<span>${t('onboarding_api_key_label')}</span>
@@ -162,8 +186,6 @@ function _renderOnboardingBody(){
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
<p class="onboarding-copy">${keyHelp}</p>`;
}
const providerSel=$('onboardingProviderSelect');
if(providerSel) providerSel.value=ONBOARDING.form.provider;
return;
}
@@ -171,7 +193,7 @@ function _renderOnboardingBody(){
body.innerHTML=`
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
</label>
<label class="onboarding-field">
<span>${t('onboarding_api_key_label')}</span>
@@ -181,8 +203,6 @@ function _renderOnboardingBody(){
<p class="onboarding-copy">${keyHelp}</p>
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
const providerSel=$('onboardingProviderSelect');
if(providerSel) providerSel.value=ONBOARDING.form.provider;
return;
}
@@ -224,12 +244,19 @@ function _renderOnboardingBody(){
<div><strong>${t('onboarding_provider_label')}</strong><span>${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_model_label')}</strong><span>${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_workspace_label')}</strong><span>${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_check_password')}</strong><span>${ONBOARDING.form.password?t('onboarding_password_will_enable'):t('onboarding_password_skipped')}</span></div>
<div><strong>${t('onboarding_check_password')}</strong><span>${t(_getOnboardingPasswordSummaryKey(settings))}</span></div>
</div>
${ONBOARDING.form.baseUrl?`<p class="onboarding-copy"><strong>${t('onboarding_base_url_label')}</strong> ${esc(ONBOARDING.form.baseUrl)}</p>`:''}
<p class="onboarding-copy">${t('onboarding_finish_help')}</p>`;
}
function _getOnboardingPasswordSummaryKey(settings){
const hasExistingPassword=!!(settings&&settings.password_enabled);
const hasNewPassword=!!((ONBOARDING.form.password||'').trim());
if(hasNewPassword) return hasExistingPassword?'onboarding_password_will_replace':'onboarding_password_will_enable';
return hasExistingPassword?'onboarding_password_keep_existing':'onboarding_password_remains_disabled';
}
function syncOnboardingWorkspaceSelect(value){
ONBOARDING.form.workspace=value;
const input=$('onboardingWorkspaceInput');
@@ -259,7 +286,7 @@ async function loadOnboardingWizard(){
const current=((status.setup||{}).current)||{};
ONBOARDING.form.provider=current.provider||'openrouter';
ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||'';
ONBOARDING.form.model=status.settings.default_model||current.model||'openai/gpt-5.4-mini';
ONBOARDING.form.model=status.settings.default_model||current.model||'';
ONBOARDING.form.password='';
ONBOARDING.form.apiKey='';
ONBOARDING.form.baseUrl=current.base_url||'';
@@ -289,7 +316,14 @@ async function _saveOnboardingProviderSetup(){
const baseUrl=(ONBOARDING.form.baseUrl||'').trim();
const current=_getOnboardingCurrentSetup();
const isUnchanged=current.provider===provider&&((current.model||'')===model)&&((current.base_url||'')===baseUrl);
if(isUnchanged && !apiKey && (ONBOARDING.status.system||{}).chat_ready) return;
// Skip the POST when nothing changed. We also skip when the provider is
// unsupported/OAuth-based and already working — chat_ready may be false for
// providers not in the quick-setup list (e.g. minimax-cn) even though they are
// fully configured. Posting in that case would either be a no-op (the server
// just marks complete for unsupported providers) or could silently overwrite
// config.yaml if the user accidentally changed the provider dropdown.
const currentIsOauth=!!(ONBOARDING.status&&ONBOARDING.status.setup&&ONBOARDING.status.setup.current_is_oauth);
if(isUnchanged && !apiKey && ((ONBOARDING.status.system||{}).chat_ready || currentIsOauth)) return;
const body={provider,model};
if(apiKey) body.api_key=apiKey;
if(baseUrl) body.base_url=baseUrl;
@@ -307,9 +341,13 @@ async function _saveOnboardingDefaults(){
if(!known){
await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path:workspace})});
}
const body={default_workspace:workspace,default_model:model};
// Model persisted by /api/onboarding/setup — no /api/default-model call needed here
const body={default_workspace:workspace};
if(password) body._set_password=password;
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
if(ONBOARDING.status){
ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled};
}
localStorage.setItem('hermes-webui-model',model);
if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect'));
}
@@ -330,6 +368,18 @@ async function _finishOnboarding(){
}
}
async function skipOnboarding(){
try{
// Mark onboarding completed server-side without changing any config
await api('/api/onboarding/complete',{method:'POST',body:'{}'});
ONBOARDING.active=false;
$('onboardingOverlay').style.display='none';
showToast(t('onboarding_skipped')||'Setup skipped');
}catch(e){
_setOnboardingNotice((e.message||String(e)),'warn');
}
}
async function nextOnboardingStep(){
try{
if(ONBOARDING.steps[ONBOARDING.step]==='setup'){

File diff suppressed because it is too large Load Diff

View File

@@ -10,66 +10,304 @@ const ICONS={
more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
};
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
let _sessionViewedCounts = null;
function _getSessionViewedCounts() {
if (_sessionViewedCounts !== null) return _sessionViewedCounts;
try {
const parsed = JSON.parse(localStorage.getItem(SESSION_VIEWED_COUNTS_KEY) || '{}');
_sessionViewedCounts = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (_){
_sessionViewedCounts = {};
}
return _sessionViewedCounts;
}
function _saveSessionViewedCounts() {
try {
localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(_getSessionViewedCounts()));
} catch (_){
// Ignore localStorage write failures.
}
}
function _setSessionViewedCount(sid, messageCount = 0) {
if (!sid) return;
const counts = _getSessionViewedCounts();
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
counts[sid] = next;
_saveSessionViewedCounts();
}
function _hasUnreadForSession(s) {
if (!s || !s.session_id) return false;
const counts = _getSessionViewedCounts();
if (!Object.prototype.hasOwnProperty.call(counts, s.session_id)) {
_setSessionViewedCount(s.session_id, Number(s.message_count || 0));
return false;
}
if (!Number.isFinite(s.message_count)) return false;
return s.message_count > Number(counts[s.session_id] || 0);
}
async function newSession(flash){
MSG_QUEUE.length=0;updateQueueBadge();
updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
// Use profile default workspace for new sessions after a profile switch (one-shot),
// otherwise inherit from the current session (or let server pick the default)
const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null);
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
// One-shot profile-switch workspace: applied to the first new session after a profile
// switch, then cleared. Use a dedicated flag so S._profileDefaultWorkspace (the
// persistent boot/settings default) is not consumed and remains available for the
// blank-page display on all subsequent returns to the empty state (#823).
const switchWs=S._profileSwitchWorkspace;
S._profileSwitchWorkspace=null;
const inheritWs=switchWs||(S.session?S.session.workspace:null)||(S._profileDefaultWorkspace||null);
// Use the saved default model for new sessions (#872). The user's saved
// default_model (from Settings) takes priority over the chat-header dropdown
// value, which reflects the *previous* session's model. Fall back to the
// dropdown value only when no default_model is configured.
const newModel=window._defaultModel||$('modelSelect').value;
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:newModel,workspace:inheritWs,profile:S.activeProfile||'default'})});
S.session=data.session;S.messages=data.session.messages||[];
S.lastUsage={...(data.session.last_usage||{})};
if(flash)S.session._flash=true;
localStorage.setItem('hermes-webui-session',S.session.session_id);
syncTopbar();await loadDir('.');renderMessages();
_setSessionViewedCount(S.session.session_id, S.session.message_count || 0);
// Sync chat-header dropdown to the session's model so the UI reflects
// the default model the server actually used (#872).
if(S.session.model && S.session.model!==$('modelSelect').value && typeof _applyModelToDropdown==='function'){
_applyModelToDropdown(S.session.model,$('modelSelect'));
if(typeof syncModelChip==='function') syncModelChip();
}
// Reset per-session visual state: a fresh chat is idle even if another
// conversation is still streaming in the background.
S.busy=false;
S.activeStreamId=null;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
setComposerStatus('');
updateQueueBadge(S.session.session_id);
syncTopbar();renderMessages();loadDir('.');
// don't call renderSessionList here - callers do it when needed
}
async function loadSession(sid){
stopApprovalPolling();hideApprovalCard();
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
if(typeof stopClarifyPolling==='function') stopClarifyPolling();
if(typeof hideClarifyCard==='function') hideClarifyCard();
// Show loading indicator immediately for responsiveness.
// Cleared by renderMessages() once full session data arrives.
const currentSid = S.session ? S.session.session_id : null;
if (currentSid !== sid) {
S.messages = [];
S.toolCalls = [];
const _msgInner = $('msgInner');
if (_msgInner) _msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Loading conversation...</div>';
}
// Phase 1: Load metadata only (~1KB) for fast session switching.
// Guard against network/server failures to prevent a permanently stuck loading state.
let data;
try {
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
} catch(e) {
const _msgInner = $('msgInner');
if(_msgInner){
if(e.status===404){
_msgInner.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Session not available in web UI.</div>';
} else {
_msgInner.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Failed to load session. Try switching sessions or refreshing.</div>';
if(typeof showToast==='function') showToast('Failed to load session',3000,'error');
}
}
return;
}
S.session=data.session;
S.session._modelResolutionDeferred=true;
S.lastUsage={...(data.session.last_usage||{})};
_setSessionViewedCount(S.session.session_id, Number(data.session.message_count || 0));
localStorage.setItem('hermes-webui-session',S.session.session_id);
// B9: sanitize empty assistant messages that can appear when agent only ran tool calls
data.session.messages=(data.session.messages||[]).filter(m=>{
if(!m||!m.role)return false;
if(m.role==='tool')return false;
if(m.role==='assistant'){let c=m.content||'';if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('');return String(c).trim().length>0;}
return true;
});
const activeStreamId=S.session.active_stream_id||null;
// Phase 2a: If session is streaming, restore from INFLIGHT cache before
// loading full messages (INFLIGHT state is self-contained and sufficient).
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
const stored=loadInflightState(sid, activeStreamId);
if(stored){
INFLIGHT[sid]={
messages:Array.isArray(stored.messages)&&stored.messages.length?stored.messages:[],
uploaded:Array.isArray(stored.uploaded)?stored.uploaded:[],
toolCalls:Array.isArray(stored.toolCalls)?stored.toolCalls:[],
reattach:true,
};
}
}
if(INFLIGHT[sid]){
// Streaming session: use cached INFLIGHT messages (already has pending assistant output).
S.messages=INFLIGHT[sid].messages;
// Restore live tool cards for this in-flight session
S.toolCalls=(INFLIGHT[sid].toolCalls||[]);
S.busy=true;
syncTopbar();renderMessages();appendThinking();loadDir('.');
clearLiveToolCards();
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
for(const tc of (S.toolCalls||[])){
if(tc&&tc.name) appendLiveToolCard(tc);
}
syncTopbar();await loadDir('.');renderMessages();appendThinking();
setBusy(true);setComposerStatus('');
startApprovalPolling(sid);
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
S.activeStreamId=activeStreamId;
const _cb=$('btnCancel');if(_cb&&activeStreamId)_cb.style.display='inline-flex';
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
INFLIGHT[sid].reattach=false;
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
}
}else{
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
S.messages=data.session.messages||[];
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
// Reset per-session visual state: the viewed session is idle even if another
// session's stream is still running in the background.
// We directly update the DOM instead of calling setBusy(false), because
// setBusy(false) drains MSG_QUEUE which we don't want here.
S.busy=false;
S.activeStreamId=null;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
setComposerStatus('');
clearLiveToolCards();
syncTopbar();await loadDir('.');renderMessages();highlightCode();
// Phase 2b: Idle session — load full messages lazily for rendering.
// _ensureMessagesLoaded is idempotent; it skips if S.messages already populated.
try {
await _ensureMessagesLoaded(sid);
} catch (e) {
// Network errors, server failures, or SSE drops (Chrome error codes 4/5)
// can cause _ensureMessagesLoaded to throw. Without a try/catch here the
// "Loading conversation..." div injected at the top of loadSession would
// persist forever with no recovery path.
const _msgInner = $('msgInner');
if (_msgInner) {
_msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Failed to load messages. Try switching sessions or refreshing.</div>';
}
if (typeof showToast === 'function') showToast('Failed to load conversation messages', 3000, 'error');
return;
}
// Restore any queued message that survived page refresh via sessionStorage.
if(typeof queueSessionMessage==='function'){
try{
const _storedQ=sessionStorage.getItem('hermes-queue-'+sid);
if(_storedQ){
const _entries=JSON.parse(_storedQ);
if(Array.isArray(_entries)&&_entries.length){
const _lastMsg=S.messages.slice().reverse()
.find(m=>m&&m.role==='assistant');
const _lastAsst=_lastMsg?(_lastMsg.timestamp||_lastMsg._ts||0)*1000:0;
const _fresh=_entries.filter(e=>!e._queued_at||e._queued_at>_lastAsst);
if(_fresh.length){
const _first=_fresh[0];
const _msg=$&&$('msg');
if(_msg&&_first.text&&!_msg.value){
_msg.value=_first.text||'';
if(typeof autoResize==='function') autoResize();
if(typeof showToast==='function') showToast((_fresh.length>1?`${_fresh.length} queued messages restored (showing first)`:'Queued message restored')+' — review and send when ready');
}
sessionStorage.removeItem('hermes-queue-'+sid);
} else {
sessionStorage.removeItem('hermes-queue-'+sid);
}
} else {
sessionStorage.removeItem('hermes-queue-'+sid);
}
}
}catch(_){sessionStorage.removeItem('hermes-queue-'+sid);}
}
// Reconstruct tool calls from message metadata, or fall back to session-level summary.
// (hasMessageToolMetadata already computed inside _ensureMessagesLoaded; S.toolCalls set there.)
updateQueueBadge(sid);
// Attach pending user message if one is queued.
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(S.session):null;
if(pendingMsg) S.messages.push(pendingMsg);
if(activeStreamId){
S.busy=true;
S.activeStreamId=activeStreamId;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='inline-flex';
setStatus('');
setComposerStatus('');
syncTopbar();renderMessages();appendThinking();loadDir('.');
updateQueueBadge(sid);
startApprovalPolling(sid);
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
}else{
S.busy=false;
S.activeStreamId=null;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
setComposerStatus('');
updateQueueBadge(sid);
syncTopbar();renderMessages();highlightCode();loadDir('.');
}
}
// Sync context usage indicator from session data
const _s=S.session;
if(_s&&typeof _syncCtxIndicator==='function'){
const u=S.lastUsage||{};
_syncCtxIndicator({input_tokens:_s.input_tokens||u.input_tokens||0,output_tokens:_s.output_tokens||u.output_tokens||0,estimated_cost:_s.estimated_cost||u.estimated_cost,context_length:u.context_length||0,last_prompt_tokens:u.last_prompt_tokens||0,threshold_tokens:u.threshold_tokens||0});
const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt);
_syncCtxIndicator({
input_tokens: _pick(u.input_tokens, _s.input_tokens),
output_tokens: _pick(u.output_tokens, _s.output_tokens),
estimated_cost: _pick(u.estimated_cost, _s.estimated_cost),
context_length: _pick(u.context_length, _s.context_length),
last_prompt_tokens:_pick(u.last_prompt_tokens,_s.last_prompt_tokens),
threshold_tokens: _pick(u.threshold_tokens, _s.threshold_tokens),
});
}
_resolveSessionModelForDisplaySoon(sid);
}
function _resolveSessionModelForDisplaySoon(sid){
if(!sid) return;
setTimeout(async()=>{
try{
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=1`);
const model=data&&data.session&&data.session.model;
if(!model||!S.session||S.session.session_id!==sid) return;
S.session.model=model;
S.session._modelResolutionDeferred=false;
syncTopbar();
}catch(_){
// Keep session switching non-blocking; the next load can try again.
}
},0);
}
// Load session messages if not already present.
// Called after loadSession fetches metadata (messages=0).
// Idempotent: if messages are already in S.messages, resolves immediately.
// Handles streaming sessions specially: restores from INFLIGHT cache or API.
async function _ensureMessagesLoaded(sid) {
// Already have messages? (e.g. from INFLIGHT restore path, already set)
if (S.messages && S.messages.length > 0 && S.messages[0] && S.messages[0].role) {
return;
}
// Fetch full session with messages
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0`);
const msgs = (data.session.messages || []).filter(m => m && m.role);
// Check for tool-call metadata on messages (for tool-call card rendering)
const hasMessageToolMetadata = msgs.some(m => {
if (!m || m.role !== 'assistant') return false;
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
const hasTu = Array.isArray(m.content) && m.content.some(p => p && p.type === 'tool_use');
return hasTc || hasTu;
});
if (!hasMessageToolMetadata && data.session.tool_calls && data.session.tool_calls.length) {
S.toolCalls = data.session.tool_calls.map(tc => ({...tc, done: true}));
} else {
S.toolCalls = [];
}
clearLiveToolCards();
S.messages = msgs;
if(S.session&&S.session.session_id===sid){
S.session.message_count=Number(data.session.message_count || msgs.length);
S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
_setSessionViewedCount(sid, Number(S.session.message_count || msgs.length));
}
}
@@ -144,8 +382,8 @@ function _openSessionActionMenu(session, anchorEl){
const menu=document.createElement('div');
menu.className='session-action-menu open';
menu.appendChild(_buildSessionAction(
session.pinned?'Unpin conversation':'Pin conversation',
session.pinned?'Remove from the pinned section':'Keep this conversation at the top',
session.pinned?t('session_unpin'):t('session_pin'),
session.pinned?t('session_unpin_desc'):t('session_pin_desc'),
session.pinned?ICONS.pin:ICONS.unpin,
async()=>{
closeSessionActionMenu();
@@ -155,13 +393,13 @@ function _openSessionActionMenu(session, anchorEl){
session.pinned=newPinned;
if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;
renderSessionList();
}catch(err){showToast('Pin failed: '+err.message);}
}catch(err){showToast(t('session_pin_failed')+err.message);}
},
session.pinned?'is-active':''
));
menu.appendChild(_buildSessionAction(
'Move to project',
session.project_id?'Change which project this conversation belongs to':'Assign this conversation to a project',
t('session_move_project'),
session.project_id?t('session_move_project_desc_has'):t('session_move_project_desc_none'),
ICONS.folder,
async()=>{
closeSessionActionMenu();
@@ -169,8 +407,8 @@ function _openSessionActionMenu(session, anchorEl){
}
));
menu.appendChild(_buildSessionAction(
session.archived?'Restore conversation':'Archive conversation',
session.archived?'Bring this conversation back into the main list':'Hide this conversation until archived is shown',
session.archived?t('session_restore'):t('session_archive'),
session.archived?t('session_restore_desc'):t('session_archive_desc'),
session.archived?ICONS.unarchive:ICONS.archive,
async()=>{
closeSessionActionMenu();
@@ -179,13 +417,13 @@ function _openSessionActionMenu(session, anchorEl){
session.archived=!session.archived;
if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived;
await renderSessionList();
showToast(session.archived?'Session archived':'Session restored');
}catch(err){showToast('Archive failed: '+err.message);}
showToast(session.archived?t('session_archived'):t('session_restored'));
}catch(err){showToast(t('session_archive_failed')+err.message);}
}
));
menu.appendChild(_buildSessionAction(
'Duplicate conversation',
'Create a copy with the same workspace and model',
t('session_duplicate'),
t('session_duplicate_desc'),
ICONS.dup,
async()=>{
closeSessionActionMenu();
@@ -195,14 +433,14 @@ function _openSessionActionMenu(session, anchorEl){
await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:res.session.session_id,title:(session.title||'Untitled')+' (copy)'})});
await loadSession(res.session.session_id);
await renderSessionList();
showToast('Session duplicated');
showToast(t('session_duplicated'));
}
}catch(err){showToast('Duplicate failed: '+err.message);}
}catch(err){showToast(t('session_duplicate_failed')+err.message);}
}
));
menu.appendChild(_buildSessionAction(
'Delete conversation',
'Permanently remove this conversation',
t('session_delete'),
t('session_delete_desc'),
ICONS.trash,
async()=>{
closeSessionActionMenu();
@@ -247,30 +485,136 @@ async function renderSessionList(){
]);
_allSessions = sessData.sessions||[];
_allProjects = projData.projects||[];
const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming));
if (isStreaming) {
startStreamingPoll();
} else {
stopStreamingPoll();
}
ensureSessionTimeRefreshPoll();
renderSessionListFromCache(); // no-ops if rename is in progress
}catch(e){console.warn('renderSessionList',e);}
}
// ── Gateway session SSE (real-time sync for agent sessions) ──
let _gatewaySSE = null;
let _gatewayPollTimer = null;
let _gatewayProbeInFlight = false;
let _gatewaySSEWarningShown = false;
const _gatewayFallbackPollMs = 30000;
const _streamingPollMs = 5000;
const _sessionTimeRefreshMs = 60000;
let _streamingPollTimer = null;
let _sessionTimeRefreshTimer = null;
function startStreamingPoll(){
if(_streamingPollTimer) return;
_streamingPollTimer = setInterval(() => {
void renderSessionList();
}, _streamingPollMs);
}
function stopStreamingPoll(){
if(!_streamingPollTimer) return;
clearInterval(_streamingPollTimer);
_streamingPollTimer = null;
}
function ensureSessionTimeRefreshPoll(){
if(_sessionTimeRefreshTimer) return;
_sessionTimeRefreshTimer = setInterval(() => {
renderSessionListFromCache();
}, _sessionTimeRefreshMs);
}
function startGatewayPollFallback(ms){
const intervalMs = Math.max(5000, Number(ms) || _gatewayFallbackPollMs);
if(_gatewayPollTimer) clearInterval(_gatewayPollTimer);
_gatewayPollTimer = setInterval(() => { renderSessionList(); }, intervalMs);
}
function stopGatewayPollFallback(){
if(_gatewayPollTimer){
clearInterval(_gatewayPollTimer);
_gatewayPollTimer = null;
}
}
async function probeGatewaySSEStatus(){
if(_gatewayProbeInFlight || !window._showCliSessions) return;
_gatewayProbeInFlight = true;
try{
const resp = await fetch('/api/sessions/gateway/stream?probe=1', { credentials:'same-origin' });
const data = await resp.json().catch(() => ({}));
if(resp.ok && data.watcher_running){
stopGatewayPollFallback();
_gatewaySSEWarningShown = false;
return;
}
if(resp.status === 503 || data.watcher_running === false){
startGatewayPollFallback(data.fallback_poll_ms || _gatewayFallbackPollMs);
renderSessionList();
if(!_gatewaySSEWarningShown && typeof showToast === 'function'){
showToast('Gateway sync unavailable — falling back to periodic refresh.', 5000);
_gatewaySSEWarningShown = true;
}
}
}catch(e){
// Network error during probe — server may be unreachable.
// Start fallback polling as a safe default; it will self-cancel
// when the SSE connection recovers and sessions_changed fires.
startGatewayPollFallback(_gatewayFallbackPollMs);
renderSessionList();
}finally{
_gatewayProbeInFlight = false;
}
}
function startGatewaySSE(){
stopGatewaySSE();
if(!window._showCliSessions) return;
try{
_gatewaySSE = new EventSource('/api/sessions/gateway/stream');
_gatewaySSE = new EventSource('api/sessions/gateway/stream');
_gatewaySSE.addEventListener('sessions_changed', (ev) => {
try{
const data = JSON.parse(ev.data);
if(data.sessions){
stopGatewayPollFallback();
_gatewaySSEWarningShown = false;
renderSessionList(); // re-fetch and re-render
// If the active session received new gateway messages, refresh the conversation view.
// S.busy check prevents stomping on an in-progress WebUI response.
// is_cli_session check ensures we only poll import_cli for CLI-originated sessions.
if(S.session && !S.busy && S.session.is_cli_session){
const changedIds = new Set((data.sessions||[]).map(s=>s.session_id));
if(changedIds.has(S.session.session_id)){
// Capture active session ID before async fetch — race guard.
// If the user switches sessions while the fetch is in-flight, discard the result.
const activeSid = S.session.session_id;
api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:activeSid})})
.then(res=>{
if(!S.session || S.session.session_id !== activeSid) return;
if(res && res.session && Array.isArray(res.session.messages)){
const prev = S.messages.length;
S.messages = res.session.messages.filter(m=>m&&m.role);
if(S.messages.length !== prev){
renderMessages();
if(typeof highlightCode==='function') highlightCode();
}
}
})
.catch(()=>{ /* ignore — next poll will retry */ });
}
}
}
}catch(e){ /* ignore parse errors */ }
});
_gatewaySSE.onerror = () => {
// EventSource auto-reconnects; no action needed
void probeGatewaySSEStatus();
};
}catch(e){ /* SSE not available */ }
}catch(e){
void probeGatewaySSEStatus();
}
}
function stopGatewaySSE(){
@@ -278,6 +622,9 @@ function stopGatewaySSE(){
_gatewaySSE.close();
_gatewaySSE = null;
}
stopGatewayPollFallback();
_gatewayProbeInFlight = false;
_gatewaySSEWarningShown = false;
}
let _searchDebounceTimer = null;
@@ -300,6 +647,72 @@ function filterSessions(){
}, 350);
}
function _sessionTimestampMs(session) {
const raw = Number(session && (session.last_message_at || session.updated_at || session.created_at || 0));
return Number.isFinite(raw) ? raw * 1000 : 0;
}
function _localDayOrdinal(timestampMs) {
const date = new Date(timestampMs);
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);
}
function _sessionCalendarBoundaries(nowMs = Date.now()) {
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startOfYesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const startOfWeek = new Date(startOfToday);
startOfWeek.setDate(startOfWeek.getDate() - ((startOfWeek.getDay() + 6) % 7));
const startOfLastWeek = new Date(startOfWeek);
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
return {
startOfToday: startOfToday.getTime(),
startOfYesterday: startOfYesterday.getTime(),
startOfWeek: startOfWeek.getTime(),
startOfLastWeek: startOfLastWeek.getTime(),
};
}
function _formatSessionDate(timestampMs, nowMs = Date.now()) {
const date = new Date(timestampMs);
const now = new Date(nowMs);
const options = {month:'short', day:'numeric'};
if (date.getFullYear() !== now.getFullYear()) options.year = 'numeric';
return date.toLocaleDateString(undefined, options);
}
function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) {
if (!timestampMs) return t('session_time_unknown');
const diffMs = Math.max(0, nowMs - timestampMs);
const minute = 60 * 1000;
const hour = 60 * minute;
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
const dayDiff = Math.max(0, _localDayOrdinal(nowMs) - _localDayOrdinal(timestampMs));
if (timestampMs >= startOfToday) {
if (diffMs < minute) return t('session_time_just_now');
if (diffMs < hour) {
const minutes = Math.floor(diffMs / minute);
return t('session_time_minutes_ago', minutes);
}
const hours = Math.floor(diffMs / hour);
return t('session_time_hours_ago', hours);
}
if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday');
if (timestampMs >= startOfWeek) return t('session_time_days_ago', dayDiff);
if (timestampMs >= startOfLastWeek) return t('session_time_last_week');
return _formatSessionDate(timestampMs, nowMs);
}
function _sessionTimeBucketLabel(timestampMs, nowMs = Date.now()) {
if (!timestampMs) return t('session_time_bucket_older');
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
if (timestampMs >= startOfToday) return t('session_time_bucket_today');
if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday');
if (timestampMs >= startOfWeek) return t('session_time_bucket_this_week');
if (timestampMs >= startOfLastWeek) return t('session_time_bucket_last_week');
return t('session_time_bucket_older');
}
function renderSessionListFromCache(){
// Don't re-render while user is actively renaming a session (would destroy the input)
if(_renamingSid) return;
@@ -386,12 +799,12 @@ function renderSessionListFromCache(){
empty.textContent='No sessions in this project yet.';
list.appendChild(empty);
}
const orderedSessions=[...sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
// Separate pinned from unpinned
const pinned=sessions.filter(s=>s.pinned);
const unpinned=sessions.filter(s=>!s.pinned);
// Date grouping: Pinned / Today / Yesterday / Earlier
const pinned=orderedSessions.filter(s=>s.pinned);
const unpinned=orderedSessions.filter(s=>!s.pinned);
// Date grouping: Pinned / Today / Yesterday / This week / Last week / Older
const now=Date.now();
const ONE_DAY=86400000;
// Collapse state persisted in localStorage
let _groupCollapsed={};
try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){}
@@ -401,8 +814,8 @@ function renderSessionListFromCache(){
let curLabel=null,curItems=[];
if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true});
for(const s of unpinned){
const ts=(s.updated_at||s.created_at||0)*1000;
const label=ts>now-ONE_DAY?'Today':ts>now-2*ONE_DAY?'Yesterday':'Earlier';
const ts=_sessionTimestampMs(s);
const label=_sessionTimeBucketLabel(ts, now);
if(label!==curLabel){
if(curItems.length) groups.push({label:curLabel,items:curItems});
curLabel=label;curItems=[s];
@@ -417,7 +830,7 @@ function renderSessionListFromCache(){
hdr.className='session-date-header'+(g.isPinned?' pinned':'');
const caret=document.createElement('span');
caret.className='session-date-caret';
caret.textContent='\u25B8'; // right-pointing triangle
caret.textContent='\u25BE'; // down when expanded; rotated right when collapsed
const label=document.createElement('span');
label.textContent=g.label;
hdr.appendChild(caret);hdr.appendChild(label);
@@ -432,25 +845,70 @@ function renderSessionListFromCache(){
_saveCollapsed();
};
wrapper.appendChild(hdr);
for(const s of g.items){ body.appendChild(_renderOneSession(s)); }
for(const s of g.items){ body.appendChild(_renderOneSession(s, Boolean(g.isPinned))); }
wrapper.appendChild(body);
list.appendChild(wrapper);
}
// ── Render session items (extracted for group body use) ──
// Note: declared after the groups loop but available via function hoisting.
function _renderOneSession(s){
function _renderOneSession(s, isPinnedGroup=false){
const el=document.createElement('div');
const isActive=S.session&&s.session_id===S.session.session_id;
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(s.is_cli_session?' cli-session':'');
if(s.source_tag) el.dataset.source=s.source_tag;
const isLocalStreaming=Boolean(
s.session_id
&& (
(isActive&&S.busy)
|| (typeof INFLIGHT==='object'&&INFLIGHT&&INFLIGHT[s.session_id])
)
);
const isStreaming=Boolean(s.is_streaming||isLocalStreaming);
const hasUnread=_hasUnreadForSession(s)&&!isActive;
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(isStreaming?' streaming':'')+(hasUnread?' unread':'');
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
const rawTitle=s.title||'Untitled';
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
const cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle;
let cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle;
// Guard: system prompt content must never surface as a visible session title
if(cleanTitle.startsWith('[SYSTEM:')){
cleanTitle='Session';
}
const sessionText=document.createElement('div');
sessionText.className='session-text';
const titleRow=document.createElement('div');
titleRow.className='session-title-row';
if(s.pinned&&!isPinnedGroup){
const pinInd=document.createElement('span');
pinInd.className='session-pin-indicator';
pinInd.innerHTML=ICONS.pin;
titleRow.appendChild(pinInd);
}
const title=document.createElement('span');
title.className='session-title';
title.textContent=cleanTitle||'Untitled';
title.title='Double-click to rename';
const tsMs=_sessionTimestampMs(s);
const ts=document.createElement('span');
const hasAttentionState=isStreaming||hasUnread;
ts.className='session-time'+(hasAttentionState?' is-hidden':'');
ts.textContent=hasAttentionState?'':_formatRelativeSessionTime(tsMs);
titleRow.appendChild(title);
titleRow.appendChild(ts);
sessionText.appendChild(titleRow);
const density=(window._sidebarDensity==='detailed'?'detailed':'compact');
if(density==='detailed'){
const metaBits=[];
const msgCount=typeof s.message_count==='number'?s.message_count:0;
const msgLabel=(typeof t==='function')
? t('session_meta_messages', msgCount)
: `${msgCount} msg${msgCount===1?'':'s'}`;
metaBits.push(msgLabel);
if(s.model) metaBits.push(s.model);
if(_showAllProfiles&&s.profile) metaBits.push(s.profile);
const meta=document.createElement('div');
meta.className='session-meta';
meta.textContent=metaBits.join(' · ');
sessionText.appendChild(meta);
}
// Append tag chips after the title text
for(const tag of tags){
const chip=document.createElement('span');
@@ -490,7 +948,12 @@ function renderSessionListFromCache(){
setTimeout(()=>{ if(_renamingSid===null) renderSessionListFromCache(); },50);
};
inp.onkeydown=e2=>{
if(e2.key==='Enter'){e2.preventDefault();e2.stopPropagation();finish(true);}
if(e2.key==='Enter'){
if(e2.isComposing){return;}
e2.preventDefault();
e2.stopPropagation();
finish(true);
}
if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);}
};
// onblur: cancel only -- no accidental saves
@@ -499,13 +962,6 @@ function renderSessionListFromCache(){
setTimeout(()=>{inp.focus();inp.select();},10);
};
// Pin indicator (inline, only when pinned — no space reserved otherwise)
if(s.pinned){
const pinInd=document.createElement('span');
pinInd.className='session-pin-indicator';
pinInd.innerHTML=ICONS.pin;
el.appendChild(pinInd);
}
// Project indicator: colored dot appended after the title
if(s.project_id){
const proj=_allProjects.find(p=>p.project_id===s.project_id);
@@ -517,7 +973,11 @@ function renderSessionListFromCache(){
title.appendChild(dot);
}
}
el.appendChild(title);
el.appendChild(sessionText);
const state=document.createElement('span');
state.className='session-attention-indicator session-state-indicator'+(isStreaming?' is-streaming':(hasUnread?' is-unread':''));
state.setAttribute('aria-hidden','true');
el.appendChild(state);
// Single trigger button that opens a shared dropdown menu
const actions=document.createElement('div');
actions.className='session-actions';
@@ -586,11 +1046,13 @@ async function deleteSession(sid){
if(remaining.sessions&&remaining.sessions.length){
await loadSession(remaining.sessions[0].session_id);
}else{
$('topbarTitle').textContent=window._botName||'Hermes';
$('topbarMeta').textContent='Start a new conversation';
const _tt=$('topbarTitle');if(_tt)_tt.textContent=window._botName||'Hermes';
const _tm=$('topbarMeta');if(_tm)_tm.textContent='Start a new conversation';
$('msgInner').innerHTML='';
$('emptyState').style.display='';
$('fileTree').innerHTML='';
if(typeof S!=='undefined') S.session=null;
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
}
}
showToast('Conversation deleted');
@@ -707,7 +1169,11 @@ function _startProjectCreate(bar, addBtn){
}
};
inp.onkeydown=(e)=>{
if(e.key==='Enter'){e.preventDefault();finish(true);}
if(e.key==='Enter'){
if(e.isComposing){return;}
e.preventDefault();
finish(true);
}
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
@@ -729,7 +1195,11 @@ function _startProjectRename(proj, chip){
}
};
inp.onkeydown=(e)=>{
if(e.key==='Enter'){e.preventDefault();finish(true);}
if(e.key==='Enter'){
if(e.isComposing){return;}
e.preventDefault();
finish(true);
}
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);

File diff suppressed because it is too large Load Diff

106
static/sw.js Normal file
View File

@@ -0,0 +1,106 @@
/**
* Hermes WebUI Service Worker
* Minimal PWA service worker — enables "Add to Home Screen".
* No offline caching of API responses (the UI requires a live backend).
* Caches only static shell assets so the app shell loads fast on repeat visits.
*/
// Cache version is injected by the server at request time (routes.py /sw.js handler).
// Bumps automatically whenever the git commit changes — no manual edits needed.
const CACHE_NAME = 'hermes-shell-__CACHE_VERSION__';
// Static assets that form the app shell
const SHELL_ASSETS = [
'./',
'./static/style.css',
'./static/boot.js',
'./static/ui.js',
'./static/messages.js',
'./static/sessions.js',
'./static/panels.js',
'./static/commands.js',
'./static/icons.js',
'./static/i18n.js',
'./static/workspace.js',
'./static/onboarding.js',
'./static/favicon.svg',
'./static/favicon-32.png',
'./manifest.json',
];
// Install: pre-cache the app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(SHELL_ASSETS).catch((err) => {
// Non-fatal: if any asset fails, still activate
console.warn('[sw] Shell pre-cache partial failure:', err);
});
})
);
self.skipWaiting();
});
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
)
)
);
self.clients.claim();
});
// Fetch strategy:
// - API calls (/api/*, /stream) → always network (never cache)
// - Shell assets → cache-first with network fallback
// - Everything else → network-first, fall back to offline page
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Never intercept cross-origin requests
if (url.origin !== self.location.origin) return;
// API and streaming endpoints — always go to network
if (
url.pathname.startsWith('/api/') ||
url.pathname.includes('/stream') ||
url.pathname.startsWith('/health')
) {
return; // let browser handle normally
}
// Shell assets: cache-first
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
// Cache successful GET responses for shell assets
if (
event.request.method === 'GET' &&
response.status === 200
) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
}).catch(() => {
// Offline fallback for navigation requests.
// Note: caches.match() returns a Promise (always truthy in a `||` check),
// so we must await/then to unwrap it — otherwise the `new Response(...)`
// branch is dead code and the browser falls back to its default offline page.
if (event.request.mode === 'navigate') {
return caches.match('./').then((cached) => cached || new Response(
'<html><body style="font-family:sans-serif;padding:2rem;background:#1a1a1a;color:#ccc">' +
'<h2>You are offline</h2>' +
'<p>Hermes requires a server connection. Please check your network and try again.</p>' +
'</body></html>',
{ headers: { 'Content-Type': 'text/html' } }
));
}
});
})
);
});

File diff suppressed because it is too large Load Diff

29
static/vendor/smd.min.js vendored Normal file
View File

@@ -0,0 +1,29 @@
var D=2,C=3,h=4,b=5,B=6,U=7,G=8,S=9,x=10,m=11,H=12,K=13,M=14,Q=15,w=16,q=17,W=18,P=19,Y=20,y=21,F=22,$=23,v=24,X=25,j=26,z=27,J=28,V=29,Z=30,p=31;var I=1,k=2,L=4,T=8,f=16;function ee(e){switch(e){case I:return"href";case k:return"src";case L:return"class";case T:return"checked";case f:return"start"}}var ne=e=>{switch(e){case 1:return 3;case 2:return 4;case 3:return 5;case 4:return 6;case 5:return 7;default:return 8}},te=ne;var O=24;function ae(e){let c=new Uint32Array(O);return c[0]=1,{renderer:e,text:"",pending:"",tokens:c,len:0,token:1,fence_end:0,blockquote_idx:0,hr_char:"",hr_chars:0,fence_start:0,spaces:new Uint8Array(O),indent:"",indent_len:0,table_state:0}}function ce(e){e.pending.length>0&&o(e,`
`)}function a(e){e.text.length!==0&&(e.renderer.add_text(e.renderer.data,e.text),e.text="")}function _(e){e.len-=1,e.token=e.tokens[e.len],e.renderer.end_token(e.renderer.data)}function i(e,c){(e.tokens[e.len]===24||e.tokens[e.len]===23)&&c!==25&&_(e),e.len+=1,e.tokens[e.len]=c,e.token=c,e.renderer.add_token(e.renderer.data,c)}function re(e,c,n){for(;n<=e.len;){if(e.tokens[n]===c)return n;n+=1}return-1}function l(e,c){for(e.fence_start=0;e.len>c;)_(e)}function u(e,c){let n=0;for(let t=0;t<=e.len&&(c-=e.spaces[t],!(c<0));t+=1)switch(e.tokens[t]){case 9:case 10:case 20:case 25:n=t;break}for(;e.len>n;)_(e);return c}function A(e,c){let n=-1,t=-1;for(let s=e.blockquote_idx+1;s<=e.len;s+=1)if(e.tokens[s]===25){if(e.indent_len<e.spaces[s]){t=-1;break}t=s}else e.tokens[s]===c&&(n=s);return t===-1?n===-1?(l(e,e.blockquote_idx),i(e,c),!0):(l(e,n),!1):(l(e,t),i(e,c),!0)}function g(e,c){i(e,25),e.spaces[e.len]=e.indent_len+c,E(e),e.token=103}function E(e){e.indent="",e.indent_len=0,e.pending=""}function N(e){switch(e){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:return!0;default:return!1}}function ie(e){switch(e){case 32:case 58:case 59:case 41:case 44:case 33:case 46:case 63:case 93:case 10:return!0;default:return!1}}function se(e){return N(e)||ie(e)}function o(e,c){for(let n of c){if(e.token===101){switch(n){case" ":e.indent_len+=1;continue;case" ":e.indent_len+=4;continue}let s=u(e,e.indent_len);e.indent_len=0,e.token=e.tokens[e.len],s>0&&o(e," ".repeat(s))}let t=e.pending+n;switch(e.token){case 21:case 1:case 20:case 24:case 23:switch(e.pending[0]){case void 0:e.pending=n;continue;case" ":e.pending=n,e.indent+=" ",e.indent_len+=1;continue;case" ":e.pending=n,e.indent+=" ",e.indent_len+=4;continue;case`
`:if(e.tokens[e.len]===25&&e.token===21){_(e),E(e),e.pending=n;continue}l(e,e.blockquote_idx),E(e),e.blockquote_idx=0,e.fence_start=0,e.pending=n;continue;case"#":switch(n){case"#":if(e.pending.length<6){e.pending=t;continue}break;case" ":u(e,e.indent_len),i(e,te(e.pending.length)),E(e);continue}break;case">":{let r=re(e,20,e.blockquote_idx+1);r===-1?(l(e,e.blockquote_idx),e.blockquote_idx+=1,e.fence_start=0,i(e,20)):e.blockquote_idx=r,E(e),e.pending=n;continue}case"-":case"*":case"_":if(e.hr_chars===0&&(e.hr_chars=1,e.hr_char=e.pending),e.hr_chars>0){switch(n){case e.hr_char:e.hr_chars+=1,e.pending=t;continue;case" ":e.pending=t;continue;case`
`:if(e.hr_chars<3)break;u(e,e.indent_len),e.renderer.add_token(e.renderer.data,22),e.renderer.end_token(e.renderer.data),E(e),e.hr_chars=0;continue}e.hr_chars=0}if(e.pending[0]!=="_"&&e.pending[1]===" "){A(e,23),g(e,2),o(e,t.slice(2));continue}break;case"`":if(e.pending.length<3){if(n==="`"){e.pending=t,e.fence_start=t.length;continue}e.fence_start=0;break}switch(n){case"`":e.pending.length===e.fence_start?(e.pending=t,e.fence_start=t.length):(i(e,2),E(e),e.fence_start=0,o(e,t));continue;case`
`:{u(e,e.indent_len),i(e,10),e.pending.length>e.fence_start&&e.renderer.set_attr(e.renderer.data,L,e.pending.slice(e.fence_start)),E(e),e.token=101;continue}default:e.pending=t;continue}case"+":if(n!==" ")break;A(e,23),g(e,2);continue;case"0":case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":if(e.pending[e.pending.length-1]==="."){if(n!==" ")break;A(e,24)&&e.pending!=="1."&&e.renderer.set_attr(e.renderer.data,f,e.pending.slice(0,-1)),g(e,e.pending.length+1);continue}else{let r=n.charCodeAt(0);if(r===46||N(r)){e.pending=t;continue}}break;case"|":l(e,e.blockquote_idx),i(e,27),i(e,28),e.pending="",o(e,n);continue}let s=t;if(e.token===21)e.token=e.tokens[e.len],e.renderer.add_token(e.renderer.data,21),e.renderer.end_token(e.renderer.data);else if(e.indent_len>=4){let r=0;for(;r<4;r+=1)if(e.indent[r]===" "){r=r+1;break}s=e.indent.slice(r)+t,i(e,9)}else i(e,2);E(e),o(e,s);continue;case 27:if(e.table_state===1)switch(n){case"-":case" ":case"|":case":":e.pending=t;continue;case`
`:e.table_state=2,e.pending="";continue;default:_(e),e.table_state=0;break}else switch(e.pending){case"|":i(e,28),e.pending="",o(e,n);continue;case`
`:_(e),e.pending="",e.table_state=0,o(e,n);continue}break;case 28:switch(e.pending){case"":break;case"|":i(e,29),_(e),e.pending="",o(e,n);continue;case`
`:_(e),e.table_state=Math.min(e.table_state+1,2),e.pending="",o(e,n);continue;default:i(e,29),o(e,n);continue}break;case 29:if(e.pending==="|"){a(e),_(e),e.pending="",o(e,n);continue}break;case 9:switch(t){case`
`:case`
`:case`
`:case`
`:case`
`:e.text+=`
`,e.pending="";continue;case`
`:case`
`:case`
`:case`
`:e.pending=t;continue;default:e.pending.length!==0?(a(e),_(e),e.pending=n):e.text+=n;continue}case 10:switch(n){case"`":e.pending=t;continue;case`
`:if(t.length===e.fence_start+e.fence_end+1){a(e),_(e),e.pending="",e.fence_start=0,e.fence_end=0,e.token=101;continue}e.token=101;break;case" ":if(e.pending[0]===`
`){e.pending=t,e.fence_end+=1;continue}break}e.text+=e.pending,e.pending=n,e.fence_end=1;continue;case 11:switch(n){case"`":t.length===e.fence_start+ +(e.pending[0]===" ")?(a(e),_(e),e.pending="",e.fence_start=0):e.pending=t;continue;case`
`:e.text+=e.pending,e.pending="",e.token=21,e.blockquote_idx=0,a(e);continue;case" ":e.text+=e.pending,e.pending=n;continue;default:e.text+=t,e.pending="";continue}case 103:switch(e.pending.length){case 0:if(n!=="[")break;e.pending=t;continue;case 1:if(n!==" "&&n!=="x")break;e.pending=t;continue;case 2:if(n!=="]")break;e.pending=t;continue;case 3:if(n!==" ")break;e.renderer.add_token(e.renderer.data,26),e.pending[1]==="x"&&e.renderer.set_attr(e.renderer.data,T,""),e.renderer.end_token(e.renderer.data),e.pending=" ";continue}e.token=e.tokens[e.len],e.pending="",o(e,t);continue;case 14:case 15:{let r="*",d=12;if(e.token===15&&(r="_",d=13),r===e.pending){if(a(e),r===n){_(e),e.pending="";continue}i(e,d),e.pending=n;continue}break}case 12:case 13:{let r="*",d=14;switch(e.token===13&&(r="_",d=15),e.pending){case r:r===n?e.tokens[e.len-1]===d?e.pending=t:(a(e),i(e,d),e.pending=""):(a(e),_(e),e.pending=n);continue;case r+r:let R=e.token;a(e),_(e),_(e),r!==n?(i(e,R),e.pending=n):e.pending="";continue}break}case 16:if(t==="~~"){a(e),_(e),e.pending="";continue}break;case 105:n===`
`?(a(e),i(e,30),e.pending=""):(e.token=e.tokens[e.len],e.pending[0]==="\\"?e.text+="[":e.text+="$$",e.pending="",o(e,n));continue;case 30:if(t==="\\]"||t==="$$"){a(e),_(e),e.pending="";continue}break;case 31:if(t==="\\)"||e.pending[0]==="$"){a(e),_(e),n===")"?e.pending="":e.pending=n;continue}break;case 102:t==="http://"||t==="https://"?(a(e),i(e,18),e.pending=t,e.text=t):"http:/"[e.pending.length]===n||"https:/"[e.pending.length]===n?e.pending=t:(e.token=e.tokens[e.len],o(e,n));continue;case 17:case 19:if(e.pending==="]"){a(e),n==="("?e.pending=t:(_(e),e.pending=n);continue}if(e.pending[0]==="]"&&e.pending[1]==="("){if(n===")"){let r=e.token===17?I:k,d=e.pending.slice(2);e.renderer.set_attr(e.renderer.data,r,d),_(e),e.pending=""}else e.pending+=n;continue}break;case 18:n===" "||n===`
`||n==="\\"?(e.renderer.set_attr(e.renderer.data,I,e.pending),a(e),_(e),e.pending=n):(e.text+=n,e.pending=t);continue;case 104:if(t.startsWith("<br")){if(t.length===3||n===" "||n==="/"&&(t.length===4||e.pending[e.pending.length-1]===" ")){e.pending=t;continue}if(n===">"){a(e),e.token=e.tokens[e.len],e.renderer.add_token(e.renderer.data,21),e.renderer.end_token(e.renderer.data),e.pending="";continue}}e.token=e.tokens[e.len],e.text+="<",e.pending=e.pending.slice(1),o(e,n);continue}switch(e.pending[0]){case"\\":if(e.token===19||e.token===30||e.token===31)break;switch(n){case"(":a(e),i(e,31),e.pending="";continue;case"[":e.token=105,e.pending=t;continue;case`
`:e.pending=n;continue;default:let s=n.charCodeAt(0);e.pending="",e.text+=N(s)||s>=65&&s<=90||s>=97&&s<=122?t:n;continue}case`
`:switch(e.token){case 19:case 30:case 31:break;case 3:case 4:case 5:case 6:case 7:case 8:a(e),l(e,e.blockquote_idx),e.blockquote_idx=0,e.pending=n;continue;default:a(e),e.pending=n,e.token=21,e.blockquote_idx=0;continue}break;case"<":if(e.token!==19&&e.token!==30&&e.token!==31){a(e),e.pending=t,e.token=104;continue}break;case"`":if(e.token===19)break;n==="`"?(e.fence_start+=1,e.pending=t):(e.fence_start+=1,a(e),i(e,11),e.text=n===" "||n===`
`?"":n,e.pending="");continue;case"_":case"*":{if(e.token===19||e.token===30||e.token===31||e.token===14)break;let s=12,r=14,d=e.pending[0];if(d==="_"&&(s=13,r=15),e.pending.length===1){if(d===n){e.pending=t;continue}if(n!==" "&&n!==`
`){a(e),i(e,s),e.pending=n;continue}}else{if(d===n){a(e),i(e,r),i(e,s),e.pending="";continue}if(n!==" "&&n!==`
`){a(e),i(e,r),e.pending=n;continue}}break}case"~":if(e.token!==19&&e.token!==16){if(e.pending==="~"){if(n==="~"){e.pending=t;continue}}else if(n!==" "&&n!==`
`){a(e),i(e,16),e.pending=n;continue}}break;case"$":if(e.token!==19&&e.token!==16&&e.pending==="$")if(n==="$"){e.token=105,e.pending=t;continue}else{if(se(n.charCodeAt(0)))break;a(e),i(e,31),e.pending=n;continue}break;case"[":if(e.token!==19&&e.token!==17&&e.token!==30&&e.token!==31&&n!=="]"){a(e),i(e,17),e.pending=n;continue}break;case"!":if(e.token!==19&&n==="["){a(e),i(e,19),e.pending="";continue}break;case" ":if(e.pending.length===1&&n===" ")continue;break}if(e.token!==19&&e.token!==17&&e.token!==30&&e.token!==31&&n==="h"&&(e.pending===" "||e.pending==="")){e.text+=e.pending,e.pending=n,e.token=102;continue}e.text+=e.pending,e.pending=n}a(e)}function _e(e){return{add_token:oe,end_token:de,add_text:Ee,set_attr:le,data:{nodes:[e,,,,,],index:0}}}function oe(e,c){let n=e.nodes[e.index],t;switch(c){case 1:return;case 20:t=document.createElement("blockquote");break;case 2:t=document.createElement("p");break;case 21:t=document.createElement("br");break;case 22:t=document.createElement("hr");break;case 3:t=document.createElement("h1");break;case 4:t=document.createElement("h2");break;case 5:t=document.createElement("h3");break;case 6:t=document.createElement("h4");break;case 7:t=document.createElement("h5");break;case 8:t=document.createElement("h6");break;case 12:case 13:t=document.createElement("em");break;case 14:case 15:t=document.createElement("strong");break;case 16:t=document.createElement("s");break;case 11:t=document.createElement("code");break;case 18:case 17:t=document.createElement("a");break;case 19:t=document.createElement("img");break;case 23:t=document.createElement("ul");break;case 24:t=document.createElement("ol");break;case 25:t=document.createElement("li");break;case 26:let s=t=document.createElement("input");s.type="checkbox",s.disabled=!0;break;case 9:case 10:n=n.appendChild(document.createElement("pre")),t=document.createElement("code");break;case 27:t=document.createElement("table");break;case 28:switch(n.children.length){case 0:n=n.appendChild(document.createElement("thead"));break;case 1:n=n.appendChild(document.createElement("tbody"));break;default:n=n.children[1]}t=document.createElement("tr");break;case 29:t=document.createElement(n.parentElement?.tagName==="THEAD"?"th":"td");break;case 30:t=document.createElement("equation-block");break;case 31:t=document.createElement("equation-inline");break}e.nodes[++e.index]=n.appendChild(t)}function de(e){e.index-=1}function Ee(e,c){e.nodes[e.index].appendChild(document.createTextNode(c))}function le(e,c,n){e.nodes[e.index].setAttribute(ee(c),n)}export{Y as BLOCKQUOTE,j as CHECKBOX,T as CHECKED,S as CODE_BLOCK,x as CODE_FENCE,m as CODE_INLINE,Z as EQUATION_BLOCK,p as EQUATION_INLINE,C as HEADING_1,h as HEADING_2,b as HEADING_3,B as HEADING_4,U as HEADING_5,G as HEADING_6,I as HREF,P as IMAGE,H as ITALIC_AST,K as ITALIC_UND,L as LANG,y as LINE_BREAK,q as LINK,X as LIST_ITEM,v as LIST_ORDERED,$ as LIST_UNORDERED,D as PARAGRAPH,W as RAW_URL,F as RULE,k as SRC,f as START,w as STRIKE,M as STRONG_AST,Q as STRONG_UND,z as TABLE,V as TABLE_CELL,J as TABLE_ROW,_e as default_renderer,ae as parser,ce as parser_end,o as parser_write};

View File

@@ -1,7 +1,13 @@
async function api(path,opts={}){
const url=new URL(path,location.origin);
// Strip leading slash so URL resolves relative to location.href (supports subpath mounts)
const rel = path.startsWith('/') ? path.slice(1) : path;
const url=new URL(rel,location.href);
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
if(!res.ok){
// 401 means the auth session expired. Redirect to /login so the user can
// re-authenticate. This is especially important for iOS PWA (standalone mode)
// where a server-side 302 → /login opens in Safari instead of within the PWA.
if(res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return;}
const text=await res.text();
// Parse JSON error body and surface the human-readable message,
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
@@ -95,6 +101,7 @@ function navigateUp(){
// File extension sets for preview routing (must match server-side sets)
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
const HTML_EXTS = new Set(['.html','.htm']);
// Binary formats that should download rather than preview
const DOWNLOAD_EXTS = new Set([
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
@@ -108,21 +115,25 @@ const DOWNLOAD_EXTS = new Set([
function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
let _previewCurrentPath = ''; // relative path of currently previewed file
let _previewCurrentMode = ''; // 'code' | 'md' | 'image'
let _previewCurrentMode = ''; // 'code' | 'md' | 'image' | 'html'
let _previewDirty = false; // true when edits are unsaved
function showPreview(mode){
// mode: 'code' | 'image' | 'md'
// mode: 'code' | 'image' | 'md' | 'html'
$('previewCode').style.display = mode==='code' ? '' : 'none';
$('previewImgWrap').style.display = mode==='image' ? '' : 'none';
$('previewMd').style.display = mode==='md' ? '' : 'none';
$('previewHtmlWrap').style.display = mode==='html' ? '' : 'none';
$('previewEditArea').style.display = 'none'; // start in read-only
const badge=$('previewBadge');
badge.className='preview-badge '+mode;
badge.textContent = mode==='image'?'image':mode==='md'?'md':fileExt($('previewPathText').textContent)||'text';
badge.textContent = mode==='image'?'image':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text';
_previewCurrentMode = mode;
_previewDirty = false;
updateEditBtn();
// Show "Open in browser" button only for HTML mode
const openBtn=$('btnOpenInBrowser');
if(openBtn) openBtn.style.display = mode==='html'?'inline-flex':'none';
}
function updateEditBtn(){
@@ -150,7 +161,7 @@ async function toggleEditMode(){
_previewDirty=false;
// Update read-only views
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
else $('previewMd').innerHTML=renderMd(content);
else { $('previewMd').innerHTML=renderMd(content); requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); }
$('previewEditArea').style.display='none';
if(_previewCurrentMode==='code') $('previewCode').style.display='';
else $('previewMd').style.display='';
@@ -204,7 +215,7 @@ async function openFile(path){
if(IMAGE_EXTS.has(ext)){
// Image: load via raw endpoint, show as <img>
showPreview('image');
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
$('previewImg').alt=path;
$('previewImg').src=url;
$('previewImg').onerror=()=>setStatus(t('image_load_failed'));
@@ -215,7 +226,24 @@ async function openFile(path){
showPreview('md');
_previewRawContent = data.content;
$('previewMd').innerHTML=renderMd(data.content);
requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
}catch(e){setStatus(t('file_open_failed'));}
} else if(HTML_EXTS.has(ext)){
// HTML: render in sandboxed iframe via raw endpoint.
// SECURITY TRADEOFF: We use sandbox="allow-scripts" which lets inline JS run
// but prevents access to the parent frame (origin isolation). This is a
// deliberate choice — the user is previewing their own workspace files, so
// blocking scripts entirely would break most HTML documents. The sandbox
// still prevents the preview from navigating the parent, accessing cookies,
// or reading other origin data. If a stricter mode is needed, remove
// allow-scripts (or add sandbox="") to disable all JS execution.
showPreview('html');
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`;
const iframe=$('previewHtmlIframe');
if(iframe){
iframe.src=''; // clear first to avoid stale content
iframe.src=url;
}
} else {
// Plain code / text -- but fall back to download if server signals binary
try{
@@ -237,7 +265,7 @@ async function openFile(path){
function downloadFile(path){
if(!S.session)return;
// Trigger browser download via the raw file endpoint with content-disposition attachment
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
const filename=path.split('/').pop();
const a=document.createElement('a');
a.href=url;a.download=filename;
@@ -284,3 +312,9 @@ function renderFileBreadcrumb(filePath) {
bar.appendChild(seg);
}
}
function openInBrowser(){
if(!_previewCurrentPath||!S.session) return;
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(_previewCurrentPath)}`;
window.open(url,'_blank');
}

46
tests/_pytest_port.py Normal file
View File

@@ -0,0 +1,46 @@
"""
Shared test server constants for use in individual test files.
Instead of hardcoding ``BASE = "http://127.0.0.1:8788"`` in every test file,
import from here so the port and state dir are always consistent with
what conftest.py computed for this worktree.
Usage::
from tests._pytest_port import BASE
conftest.py publishes ``HERMES_WEBUI_TEST_PORT`` and
``HERMES_WEBUI_TEST_STATE_DIR`` to ``os.environ`` at module level
(before any test file is imported), so this module always reads the
correct values. The auto-derivation fallback matches conftest's logic
exactly, so standalone imports also work correctly.
"""
import hashlib
import os
import pathlib
def _auto_test_port(repo_root: pathlib.Path) -> int:
h = int(hashlib.md5(str(repo_root).encode()).hexdigest(), 16)
return 20000 + (h % 10000)
def _auto_state_dir_name(repo_root: pathlib.Path) -> str:
h = hashlib.md5(str(repo_root).encode()).hexdigest()[:8]
return f"webui-test-{h}"
_TESTS_DIR = pathlib.Path(__file__).parent.resolve()
_REPO_ROOT = _TESTS_DIR.parent.resolve()
_HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME',
str(pathlib.Path.home() / '.hermes')))
TEST_PORT = int(os.environ.get('HERMES_WEBUI_TEST_PORT',
str(_auto_test_port(_REPO_ROOT))))
BASE = f"http://127.0.0.1:{TEST_PORT}"
TEST_STATE_DIR = pathlib.Path(os.environ.get(
'HERMES_WEBUI_TEST_STATE_DIR',
str(_HERMES_HOME / _auto_state_dir_name(_REPO_ROOT))
))
# Default model injected by conftest — tests that mutate the default model
# must restore to this value so later tests see a consistent baseline.
TEST_DEFAULT_MODEL = os.environ.get('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.4-mini')

View File

@@ -31,14 +31,37 @@ HOME = pathlib.Path.home()
HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME', str(HOME / '.hermes')))
# ── Test server config ────────────────────────────────────────────────────
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT', '8788'))
# Port and state dir auto-derive from the repo path when no env var is set,
# giving every worktree its own isolated port (8800-8899) and state directory.
# Override with HERMES_WEBUI_TEST_PORT / HERMES_WEBUI_TEST_STATE_DIR to pin.
def _auto_test_port(repo_root) -> int:
"""Map repo path to a unique port in 20000-29999 (10k range = near-zero collisions).
Far from system port ranges and Linux ephemeral ports (32768+).
Override with HERMES_WEBUI_TEST_PORT to use a specific port."""
import hashlib
h = int(hashlib.md5(str(repo_root).encode()).hexdigest(), 16)
return 20000 + (h % 10000)
def _auto_state_dir_name(repo_root) -> str:
import hashlib
h = hashlib.md5(str(repo_root).encode()).hexdigest()[:8]
return f"webui-test-{h}"
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT',
str(_auto_test_port(REPO_ROOT))))
TEST_BASE = f"http://127.0.0.1:{TEST_PORT}"
TEST_STATE_DIR = pathlib.Path(os.getenv(
'HERMES_WEBUI_TEST_STATE_DIR',
str(HERMES_HOME / 'webui-mvp-test')
str(HERMES_HOME / _auto_state_dir_name(REPO_ROOT))
))
TEST_WORKSPACE = TEST_STATE_DIR / 'test-workspace'
# Publish at module level so _pytest_port.py (imported at collection time)
# and any test file using os.environ sees the right values immediately.
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
os.environ.setdefault('HERMES_WEBUI_TEST_STATE_DIR', str(TEST_STATE_DIR))
# ── Server script: always relative to repo root ───────────────────────────
SERVER_SCRIPT = REPO_ROOT / 'server.py'
if not SERVER_SCRIPT.exists():
@@ -245,9 +268,20 @@ def test_server():
# as the server. Other test files (test_auth_sessions.py) may override
# HERMES_WEBUI_STATE_DIR for their own purposes, but HERMES_WEBUI_TEST_STATE_DIR
# is reserved for this mapping and is never overridden by individual test files.
os.environ.setdefault('HERMES_WEBUI_TEST_STATE_DIR', str(TEST_STATE_DIR))
# Export both port and state-dir as env vars so individual test files
# can read them without importing conftest (avoids circular imports).
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
# os.environ already set at module level above; no-op here.
env = os.environ.copy()
# Strip real provider keys so test subprocess never inherits production credentials.
# The test server uses a mock/isolated config — no real API calls are made.
for _k in list(env):
if any(_k.startswith(p) for p in (
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY',
'GOOGLE_API_KEY', 'DEEPSEEK_API_KEY',
)):
del env[_k]
env.update({
"HERMES_WEBUI_PORT": str(TEST_PORT),
"HERMES_WEBUI_HOST": "127.0.0.1",
@@ -255,6 +289,14 @@ def test_server():
"HERMES_WEBUI_DEFAULT_WORKSPACE": str(TEST_WORKSPACE),
"HERMES_WEBUI_DEFAULT_MODEL": "openai/gpt-5.4-mini",
"HERMES_HOME": str(TEST_STATE_DIR),
# Belt-and-suspenders: HERMES_BASE_HOME hard-locks _DEFAULT_HERMES_HOME
# in api/profiles.py to the test state dir regardless of profile switching
# or any os.environ mutation that happens inside the server process.
# Without this, a profile switch or active_profile file in the real
# ~/.hermes can redirect _get_active_hermes_home() out of the sandbox,
# causing onboarding writes (config.yaml, .env) to land in the production
# ~/.hermes/profiles/webui/ and overwrite real API keys.
"HERMES_BASE_HOME": str(TEST_STATE_DIR),
})
# Pass agent dir if discovered so server.py doesn't have to re-discover
@@ -300,6 +342,33 @@ def base_url():
return TEST_BASE
# ── Per-test model cache invalidation ────────────────────────────────────────
# The TTL cache for get_available_models() persists across tests within the
# same process. Tests that modify cfg in-memory won't trigger the mtime path,
# so the cache must be explicitly invalidated after each test that exercises
# provider/model detection.
@pytest.fixture(autouse=True)
def _invalidate_models_cache_after_test():
"""Force the TTL cache to be cleared before and after every test.
This prevents state bleed where a test that calls get_available_models()
populates the cache with a particular config, and the next test sees stale
results even though it has mutated _cfg_cache in-memory.
"""
try:
from api.config import invalidate_models_cache
invalidate_models_cache()
except Exception:
pass
yield
try:
from api.config import invalidate_models_cache
invalidate_models_cache()
except Exception:
pass
# ── Per-test session cleanup ──────────────────────────────────────────────────
@pytest.fixture(autouse=True)

View File

@@ -0,0 +1,106 @@
"""
Tests for issue #1038 — iOS PWA auth-expiry redirect.
When a 401 is returned by any API endpoint, the client-side JS should redirect
to /login rather than showing a raw error toast. On iOS PWA standalone mode a
server-side 302→/login breaks out of the PWA shell into Safari, so the fix is
client-side: workspace.js api() intercepts 401 before throwing and calls
window.location.href = '/login'.
These are static regression tests that verify the JS source contains the
correct guard patterns.
"""
import re
from pathlib import Path
ROOT = Path(__file__).parent.parent
def _workspace_js() -> str:
return (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
def _ui_js() -> str:
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
class TestPWAAuthRedirect:
def test_workspace_js_has_401_redirect(self):
"""api() in workspace.js must redirect to /login on 401."""
src = _workspace_js()
# Guard must appear inside the !res.ok block, before throwing
assert "res.status===401" in src, \
"workspace.js api() must check res.status===401"
assert "window.location.href='/login" in src or 'window.location.href="/login' in src, \
"workspace.js api() must redirect to /login on 401"
def test_workspace_js_401_before_throw(self):
"""The 401 redirect must come before the generic error throw."""
src = _workspace_js()
idx_401 = src.find("res.status===401")
idx_throw = src.find("throw new Error")
assert idx_401 != -1, "401 guard not found in workspace.js"
assert idx_throw != -1, "throw not found in workspace.js"
assert idx_401 < idx_throw, \
"401 redirect must appear before the generic throw in workspace.js"
def test_ui_js_has_redirect_helper(self):
"""ui.js must define _redirectIfUnauth helper."""
src = _ui_js()
assert "_redirectIfUnauth" in src, \
"ui.js must define _redirectIfUnauth helper function"
def test_ui_js_models_fetch_uses_redirect(self):
"""populateModelDropdown() must call _redirectIfUnauth on the api/models response."""
src = _ui_js()
# The helper must be called after the api/models fetch
assert "_redirectIfUnauth(_modelsRes)" in src, \
"populateModelDropdown() must check 401 on api/models fetch"
def test_ui_js_live_models_fetch_uses_redirect(self):
"""loadLiveModels() must call _redirectIfUnauth on the api/models/live response."""
src = _ui_js()
assert "_redirectIfUnauth(_liveRes)" in src, \
"loadLiveModels() must check 401 on api/models/live fetch"
def test_ui_js_upload_fetch_uses_redirect(self):
"""File upload must call _redirectIfUnauth on the api/upload response."""
src = _ui_js()
assert "_redirectIfUnauth(res)" in src, \
"upload fetch must call _redirectIfUnauth"
class TestLoginJsSafeNextPath:
"""login.js _safeNextPath() must honor ?next= but reject open-redirect payloads."""
@staticmethod
def _login_js():
return (Path(__file__).parent.parent / "static" / "login.js").read_text(encoding="utf-8")
def test_safe_next_path_function_exists(self):
"""login.js must define _safeNextPath() to honor the ?next= redirect."""
assert "_safeNextPath" in self._login_js(), (
"login.js must define _safeNextPath() to use the ?next= redirect after login"
)
def test_login_uses_safe_next_path(self):
"""doLogin success handler must redirect to _safeNextPath(), not hardcoded './'."""
src = self._login_js()
assert "_safeNextPath()" in src, (
"doLogin must call _safeNextPath() instead of hardcoding './'"
)
def test_safe_next_path_rejects_protocol_relative(self):
"""_safeNextPath guard must reject '//' prefix (protocol-relative open-redirect)."""
src = self._login_js()
assert "charAt(1) === '/'" in src or "startsWith('//')" in src, (
"_safeNextPath must reject protocol-relative paths like //evil.com"
)
def test_safe_next_path_rejects_non_path_absolute(self):
"""_safeNextPath guard must require path starts with '/'."""
src = self._login_js()
assert "charAt(0) !== '/'" in src or "startsWith('/')" in src, (
"_safeNextPath must reject non-path-absolute inputs (e.g. 'http://...')"
)

View File

@@ -0,0 +1,51 @@
"""
Tests for issue #1044 — Mermaid CSP font violation.
Mermaid's built-in themes inject an @import for Google Fonts (Manrope) at
render time, which is blocked by the CSP's style-src directive. Fix: pass
fontFamily:'inherit' in themeVariables so Mermaid never requests an external
font URL.
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent
def _ui_js() -> str:
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
class TestMermaidCSPFont:
def test_mermaid_init_has_font_family_inherit(self):
"""themeVariables in mermaid.initialize() must set fontFamily to 'inherit'."""
src = _ui_js()
assert "fontFamily:'inherit'" in src, (
"mermaid.initialize() themeVariables must set fontFamily:'inherit' "
"to suppress the Google Fonts (Manrope) import that violates CSP"
)
def test_mermaid_init_no_google_fonts_url(self):
"""ui.js must not contain a hardcoded fonts.googleapis.com URL."""
src = _ui_js()
assert "fonts.googleapis.com" not in src, (
"ui.js must not reference fonts.googleapis.com — use fontFamily:'inherit'"
)
def test_mermaid_font_family_inside_theme_variables_block(self):
"""fontFamily:'inherit' must be inside the themeVariables block of mermaid.initialize()."""
src = _ui_js()
init_idx = src.find("mermaid.initialize(")
assert init_idx != -1, "mermaid.initialize() call not found in ui.js"
# Find the themeVariables block after the initialize call
tv_idx = src.find("themeVariables", init_idx)
assert tv_idx != -1, "themeVariables not found inside mermaid.initialize()"
font_idx = src.find("fontFamily:'inherit'", tv_idx)
assert font_idx != -1, (
"fontFamily:'inherit' must appear inside themeVariables in mermaid.initialize()"
)
# The closing brace of themeVariables should come after fontFamily
close_brace = src.find("})", tv_idx)
assert font_idx < close_brace, (
"fontFamily:'inherit' must be inside the themeVariables block (before })"
)

View File

@@ -0,0 +1,116 @@
"""
Tests for issue #1045 — bfcache layout broken on tab restore.
When the browser restores a page from bfcache (event.persisted === true),
the async boot IIFE does not re-run. The existing pageshow handler (added for
#822) only cleared the session search field and re-rendered the session list.
This left the rail, topbar, workspace panel, and resize handles in the stale
bfcache DOM state, producing a broken layout.
Fix: extend the pageshow handler to also call syncTopbar, syncWorkspacePanelState,
_initResizePanels, and startGatewaySSE — all guarded so missing helpers degrade.
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent
def _boot_js() -> str:
return (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
class TestBfcacheLayoutRestore:
def test_pageshow_calls_sync_topbar(self):
"""pageshow handler must call syncTopbar() on bfcache restore."""
src = _boot_js()
# Find the pageshow listener block
ps_idx = src.find("window.addEventListener('pageshow'")
assert ps_idx != -1, "pageshow listener not found in boot.js"
handler_body = src[ps_idx:ps_idx + 1600]
assert "syncTopbar" in handler_body, (
"pageshow handler must call syncTopbar() to restore topbar state after bfcache"
)
def test_pageshow_calls_sync_workspace_panel_state(self):
"""pageshow handler must call syncWorkspacePanelState()."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "syncWorkspacePanelState" in handler_body, (
"pageshow handler must call syncWorkspacePanelState() on bfcache restore"
)
def test_pageshow_calls_start_gateway_sse(self):
"""pageshow handler must call startGatewaySSE() to reconnect the dead SSE connection."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "startGatewaySSE" in handler_body, (
"pageshow handler must restart gateway SSE (bfcache-persisted connections are dead)"
)
def test_pageshow_still_clears_session_search(self):
"""pageshow handler must still clear #sessionSearch (original #822 fix preserved)."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "sessionSearch" in handler_body, (
"pageshow handler must still clear #sessionSearch (regression: #822 fix must be preserved)"
)
def test_pageshow_still_calls_render_session_list_from_cache(self):
"""pageshow handler must still call renderSessionListFromCache()."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "renderSessionListFromCache" in handler_body, (
"pageshow handler must still call renderSessionListFromCache() (regression: #822 fix)"
)
def test_pageshow_does_not_call_init_resize_panels(self):
"""pageshow handler must NOT call _initResizePanels() — bfcache
preserves event listeners so re-attaching them stacks duplicates."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "_initResizePanels" not in handler_body, (
"pageshow handler must not call _initResizePanels() — it stacks "
"duplicate mousedown listeners on every bfcache restore"
)
def test_new_calls_are_guarded_with_typeof(self):
"""New calls in the pageshow handler must be typeof-guarded for safe degradation."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
# Each of the new calls must be guarded
for fn in ("syncTopbar", "syncWorkspacePanelState", "startGatewaySSE",
"closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
assert f"typeof {fn} === 'function'" in handler_body, (
f"{fn}() call in pageshow handler must be guarded with typeof === 'function'"
)
def test_pageshow_closes_all_dropdowns(self):
"""pageshow handler must close all known dropdowns to reset frozen bfcache popover state."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
for fn in ("closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
assert fn in handler_body, (
f"pageshow handler must call {fn}() to dismiss any dropdown left open by bfcache"
)
def test_dropdowns_closed_before_layout_sync(self):
"""Dropdown closes must come before layout sync calls (clean state first)."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
close_idx = handler_body.find("closeModelDropdown")
sync_idx = handler_body.find("syncTopbar")
assert close_idx != -1 and sync_idx != -1, "Both close and sync calls must be present"
assert close_idx < sync_idx, (
"Dropdown close calls must appear before layout sync calls in the pageshow handler"
)

View File

@@ -0,0 +1,104 @@
"""
Tests for #745: code blocks losing newlines when not preceded by double blank line.
Root cause: the paragraph-splitter in renderMd() replaced \n with <br> inside
<pre><code> blocks when they were not separated by a double newline from surrounding
text. The fix stashes <pre> blocks (and pre-header divs, mermaid, katex) before
the paragraph split and restores them afterwards.
"""
import re
import subprocess
import sys
import os
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
def get_ui_js():
return open(UI_JS, encoding='utf-8').read()
class TestCodeBlockNewlinePreservation:
def test_pre_stash_present(self):
"""The _pre_stash variable must exist in ui.js."""
src = get_ui_js()
assert '_pre_stash' in src, "_pre_stash not found in ui.js"
def test_pre_stash_token_E_used(self):
"""Stash token \\x00E must be used for pre-block stashing."""
src = get_ui_js()
assert r'\x00E' in src, r"\x00E stash token not found in ui.js"
def test_stash_before_paragraph_split(self):
"""_pre_stash must be populated BEFORE the parts=s.split line."""
src = get_ui_js()
pre_stash_pos = src.index('_pre_stash=[]')
split_pos = src.index('const parts=s.split(/\\n{2,}/)')
assert pre_stash_pos < split_pos, \
"_pre_stash must be initialised before the paragraph split"
def test_restore_after_paragraph_split(self):
"""_pre_stash restore must happen AFTER the paragraph map/join line."""
src = get_ui_js()
restore_pos = src.index('_pre_stash[+i]')
split_pos = src.index("}).join('\\n');", src.index('const parts=s.split'))
assert restore_pos > split_pos, \
"_pre_stash must be restored after the paragraph split/join"
def test_paragraph_split_bypasses_stash_tokens(self):
"""The paragraph map must bypass lines that start with \\x00E."""
src = get_ui_js()
# The map line must check for \x00E in its bypass condition
map_line = next(
l for l in src.splitlines()
if 'parts.map' in l and '<br>' in l
)
assert r'\x00E' in map_line, \
r"paragraph map must bypass \x00E stash tokens"
def test_pre_regex_covers_pre_header_div(self):
"""The stash regex must match <div class=\"pre-header\"> before <pre>."""
src = get_ui_js()
# Find the replacement regex used to populate _pre_stash
stash_block_idx = src.index('_pre_stash=[]')
stash_block = src[stash_block_idx:stash_block_idx + 400]
assert 'pre-header' in stash_block, \
"pre-stash regex must match <div class=\"pre-header\"> wrappers"
def test_mermaid_covered_by_stash(self):
"""The stash regex must also cover mermaid-block divs."""
src = get_ui_js()
stash_block_idx = src.index('_pre_stash=[]')
stash_block = src[stash_block_idx:stash_block_idx + 400]
assert 'mermaid-block' in stash_block, \
"pre-stash regex must cover mermaid-block divs"
def test_katex_covered_by_stash(self):
"""The stash regex must also cover katex-block divs."""
src = get_ui_js()
stash_block_idx = src.index('_pre_stash=[]')
stash_block = src[stash_block_idx:stash_block_idx + 400]
assert 'katex-block' in stash_block, \
"pre-stash regex must cover katex-block divs"
def test_js_syntax_valid(self):
"""ui.js must pass node --check after the fix."""
result = subprocess.run(
['node', '--check', UI_JS],
capture_output=True, text=True
)
assert result.returncode == 0, \
f"node --check failed:\n{result.stderr}"
def test_stash_token_e_not_used_elsewhere(self):
"""\\x00E must only appear in the pre-stash section (not reused)."""
src = get_ui_js()
occurrences = [
i for i in range(len(src))
if src[i:i+4] == r'\x00' and i + 4 < len(src) and src[i+4] == 'E'
]
# Allow 2 occurrences: the push token and the restore regex
# (may be 3 if there's also a comment mentioning it)
assert len(occurrences) >= 2, \
r"Expected at least 2 uses of \x00E (push + restore)"

View File

@@ -0,0 +1,94 @@
"""Tests for inline HTML preview in workspace panel (issue #779)."""
import pytest
def _get_routes_content():
return open("api/routes.py", encoding="utf-8").read()
def _get_workspace_js():
return open("static/workspace.js", encoding="utf-8").read()
def _get_index_html():
return open("static/index.html", encoding="utf-8").read()
def test_inline_preview_param_in_file_raw():
"""?inline=1 must bypass Content-Disposition: attachment for text/html."""
content = _get_routes_content()
assert "inline_preview" in content, (
"_handle_file_raw must read the inline query parameter"
)
assert "html_inline_ok" in content, (
"_handle_file_raw must allow HTML inline when inline_preview=True"
)
def test_iframe_uses_inline_param():
"""workspace.js must pass &inline=1 when setting the preview iframe src."""
content = _get_workspace_js()
assert "inline=1" in content, (
"workspace.js must pass ?inline=1 to api/file/raw for the HTML preview iframe"
)
def test_html_preview_iframe_exists_in_html():
"""The previewHtmlIframe element must be present in index.html."""
content = _get_index_html()
assert "previewHtmlIframe" in content, (
"index.html must contain the previewHtmlIframe element"
)
def test_html_exts_defined_in_workspace_js():
"""HTML_EXTS set must include .html and .htm."""
content = _get_workspace_js()
assert "HTML_EXTS" in content, "workspace.js must define HTML_EXTS"
assert "'.html'" in content or '".html"' in content, "HTML_EXTS must include .html"
assert "'.htm'" in content or '".htm"' in content, "HTML_EXTS must include .htm"
def test_sandbox_allows_scripts_only():
"""iframe sandbox must not include allow-same-origin (XSS risk)."""
content = _get_index_html()
# Find the sandbox attribute value
import re
sandboxes = re.findall(r'sandbox="([^"]*)"', content)
preview_sandboxes = [s for s in sandboxes if "allow" in s]
for sb in preview_sandboxes:
assert "allow-same-origin" not in sb, (
"HTML preview iframe must not have allow-same-origin (would expose parent cookies)"
)
def test_inline_html_response_sets_csp_sandbox():
"""Defense-in-depth: ?inline=1 HTML responses must set Content-Security-Policy:
sandbox so the same origin isolation applies even when the URL is opened
directly in a top-level tab (not just inside the workspace panel iframe).
Without this, a user tricked into clicking a chat link like
/api/file/raw?path=evil.html&inline=1 would render the HTML in the WebUI's
origin without any sandbox, giving the page full access to cookies and
localStorage. The CSP sandbox directive (no allow-same-origin) downgrades
the document to a unique opaque origin server-side.
"""
content = _get_routes_content()
# Find the html_inline_ok block in _handle_file_raw
idx = content.find("html_inline_ok")
assert idx != -1, "html_inline_ok block not found"
block = content[idx:idx + 2500]
assert "Content-Security-Policy" in block, (
"_handle_file_raw must set Content-Security-Policy header on inline HTML responses"
)
assert "sandbox" in block, (
"CSP must include the sandbox directive"
)
# Must NOT have allow-same-origin in the sandbox directive
csp_sections = [line for line in block.splitlines() if "sandbox" in line and "Policy" in line]
for line in csp_sections:
# The line setting the CSP header — make sure it doesn't grant same-origin
if "send_header" in line:
assert "allow-same-origin" not in line, (
"CSP sandbox must NOT include allow-same-origin — that would defeat the isolation"
)

View File

@@ -0,0 +1,92 @@
"""
Tests for #886: ordered list items always rendered as "1." regardless of position.
Root cause: when LLMs output numbered lists with blank lines between items,
the paragraph-splitter in renderMd() splits the markdown into one chunk per item,
so the ordered-list regex wraps each item in its own <ol>. Each <ol> restarts
at 1, producing "1. 1. 1." instead of "1. 2. 3.".
Fix: emit value="N" on every <li> so the correct ordinal is preserved even when
items end up in separate <ol> containers after the paragraph split.
"""
import os
import re
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
def get_ui_js():
return open(UI_JS, encoding='utf-8').read()
class TestOrderedListNumbering:
def test_li_value_attr_present_in_ordered_list_block(self):
"""The ordered-list renderer must emit value= on each <li>."""
src = get_ui_js()
# Locate the ordered-list replace block
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
# Extract a window large enough to cover the whole closure (~400 chars)
ol_block = src[ol_idx:ol_idx + 500]
assert 'value=' in ol_block, (
"Ordered-list block must emit value= attribute on <li> elements to "
"preserve numbering when items are separated by blank lines (#886)"
)
def test_li_value_uses_parsed_number(self):
"""The value= must be derived from parseInt of the captured digit, not hardcoded."""
src = get_ui_js()
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
ol_block = src[ol_idx:ol_idx + 500]
assert 'parseInt' in ol_block, (
"Ordered-list block should use parseInt() to parse the list number (#886)"
)
def test_numMatch_variable_present(self):
"""The numMatch variable (or equivalent digit capture) must exist in the OL block."""
src = get_ui_js()
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
ol_block = src[ol_idx:ol_idx + 500]
# Either numMatch or a similar digit-capture variable
assert 'numMatch' in ol_block or re.search(r'match\(/.*\\d', ol_block), (
"Ordered-list block should capture the list item number with a regex match (#886)"
)
def test_valAttr_or_value_template_present(self):
"""The <li> template must include the value attribute conditionally or unconditionally."""
src = get_ui_js()
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
ol_block = src[ol_idx:ol_idx + 500]
# Either a valAttr variable or an inline value= in the template
has_val_attr = 'valAttr' in ol_block
has_inline_value = re.search(r'<li.*value=', ol_block)
assert has_val_attr or has_inline_value, (
"Ordered-list block must have value= on <li> (via valAttr var or inline) (#886)"
)
def test_ordered_list_comment_references_issue(self):
"""A comment near the OL fix should reference the issue (#886) or the symptom."""
src = get_ui_js()
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
# Look at the 300 chars BEFORE the replace line for an explanatory comment
context = src[max(0, ol_idx - 300):ol_idx]
has_comment = '#886' in context or '1. 1. 1.' in context or 'blank lines' in context.lower()
assert has_comment, (
"Expected a comment near the OL fix explaining the blank-line issue (#886)"
)
def test_list_without_blank_lines_unaffected(self):
"""A compact list (no blank lines) should still produce one <ol> with sequential items."""
src = get_ui_js()
# Structural check: the regex still captures multi-line blocks (\\n? allows groups)
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found"
# The \\n? quantifier that allows grouping must still be present
assert '\\n?' in src[ol_idx:ol_idx + 80], (
"The \\\\n? in the ordered-list regex was removed — compact lists may break"
)

View File

@@ -0,0 +1,188 @@
"""Tests for approval queue multi-entry support (issue #527).
Previously _pending[sid] held one entry, so simultaneous approvals overwrote
each other. This PR changes submit_pending() to append to a list and adds
approval_id so /api/approval/respond can target a specific entry.
"""
import json
import pathlib
import re
import sys
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(REPO_ROOT))
ROUTES_SRC = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# Static-analysis: Python routes
# ---------------------------------------------------------------------------
def test_submit_pending_appends_to_list():
"""submit_pending() must append to a list, not overwrite."""
# The new wrapper must contain queue.append
assert "queue.append(entry)" in ROUTES_SRC, \
"submit_pending() must append entry to a list queue, not overwrite _pending[sid]"
def test_submit_pending_adds_approval_id():
"""Each queued entry must get a unique approval_id."""
assert "approval_id" in ROUTES_SRC and "uuid.uuid4().hex" in ROUTES_SRC, \
"submit_pending() must assign a uuid4 approval_id to each queued entry"
def test_handle_approval_pending_returns_count():
"""_handle_approval_pending must return pending_count in its response."""
assert '"pending_count"' in ROUTES_SRC, \
"_handle_approval_pending must include pending_count in the JSON response"
def test_handle_approval_respond_pops_by_approval_id():
"""_handle_approval_respond must target entry by approval_id."""
assert 'approval_id = body.get("approval_id"' in ROUTES_SRC, \
"_handle_approval_respond must read approval_id from request body"
assert 'entry.get("approval_id") == approval_id' in ROUTES_SRC, \
"_handle_approval_respond must find and pop the matching entry by approval_id"
def test_handle_approval_respond_fallback_to_oldest():
"""When no approval_id is given, fall back to popping the oldest entry (FIFO)."""
# The fallback path: queue.pop(0) when approval_id is empty
assert "queue.pop(0)" in ROUTES_SRC, \
"_handle_approval_respond must fall back to popping the oldest entry when approval_id is absent"
def test_backward_compat_legacy_dict_value():
"""The respond handler must tolerate a legacy single-dict value in _pending."""
assert "Legacy single-dict value" in ROUTES_SRC or \
"# Legacy single-dict" in ROUTES_SRC or \
"elif queue:" in ROUTES_SRC, \
"respond handler must handle legacy single-dict _pending values for backward compatibility"
# ---------------------------------------------------------------------------
# Static-analysis: JavaScript frontend
# ---------------------------------------------------------------------------
def test_respond_sends_approval_id():
"""respondApproval() must include approval_id in the POST body."""
assert "approval_id: approvalId" in MESSAGES_JS, \
"respondApproval() must send approval_id in the POST body to /api/approval/respond"
def test_show_approval_card_accepts_count():
"""showApprovalCard must accept a pendingCount parameter."""
assert re.search(r"function showApprovalCard\(pending,\s*pendingCount\)", MESSAGES_JS), \
"showApprovalCard() must accept a pendingCount argument"
def test_show_approval_card_renders_counter():
"""showApprovalCard must display a '1 of N pending' counter when N > 1."""
assert '"1 of " + pendingCount + " pending"' in MESSAGES_JS or \
"'1 of ' + pendingCount + ' pending'" in MESSAGES_JS, \
"showApprovalCard() must render '1 of N pending' counter for multiple queued approvals"
def test_approval_current_id_tracked():
"""_approvalCurrentId must be set and cleared around each approval."""
assert "_approvalCurrentId" in MESSAGES_JS, \
"_approvalCurrentId must track the approval_id of the currently displayed card"
assert "_approvalCurrentId = pending.approval_id" in MESSAGES_JS or \
"_approvalCurrentId = pending.approval_id || null" in MESSAGES_JS, \
"_approvalCurrentId must be assigned from pending.approval_id"
# Must be nulled on respond
assert "_approvalCurrentId = null" in MESSAGES_JS, \
"_approvalCurrentId must be cleared when respondApproval() is called"
def test_polling_passes_count_to_show():
"""The poll loop must pass pending_count to showApprovalCard."""
assert "showApprovalCard(data.pending, data.pending_count" in MESSAGES_JS, \
"Poll loop must pass data.pending_count to showApprovalCard"
# ---------------------------------------------------------------------------
# HTML: counter element present
# ---------------------------------------------------------------------------
def test_approval_counter_element_exists():
"""index.html must contain an approvalCounter element."""
assert 'id="approvalCounter"' in INDEX_HTML, \
"index.html must contain an element with id='approvalCounter' for the '1 of N' display"
# ---------------------------------------------------------------------------
# Functional: multiple entries behave correctly (via routes module directly)
# ---------------------------------------------------------------------------
def test_multiple_approvals_both_surfaced():
"""Two submit_pending calls must produce two queued entries, not one."""
import threading
from api import routes as r
# Reset state
sid = "test-multi-approval-sid"
with r._lock:
r._pending.pop(sid, None)
r.submit_pending(sid, {"command": "cmd1", "pattern_key": "p1", "pattern_keys": ["p1"], "description": "d1"})
r.submit_pending(sid, {"command": "cmd2", "pattern_key": "p2", "pattern_keys": ["p2"], "description": "d2"})
with r._lock:
queue = r._pending.get(sid)
assert isinstance(queue, list), "After two submit_pending calls, _pending[sid] must be a list"
assert len(queue) == 2, f"Expected 2 queued entries, got {len(queue)}"
assert queue[0]["command"] == "cmd1"
assert queue[1]["command"] == "cmd2"
assert queue[0].get("approval_id"), "First entry must have an approval_id"
assert queue[1].get("approval_id"), "Second entry must have an approval_id"
assert queue[0]["approval_id"] != queue[1]["approval_id"], "Each entry must have a unique approval_id"
# Cleanup
with r._lock:
r._pending.pop(sid, None)
def test_respond_by_approval_id_pops_correct_entry():
"""Responding with approval_id must remove only the targeted entry."""
from api import routes as r
sid = "test-respond-by-id-sid"
with r._lock:
r._pending.pop(sid, None)
r.submit_pending(sid, {"command": "cmd1", "pattern_key": "p1", "pattern_keys": ["p1"], "description": "d1"})
r.submit_pending(sid, {"command": "cmd2", "pattern_key": "p2", "pattern_keys": ["p2"], "description": "d2"})
with r._lock:
queue = r._pending.get(sid, [])
aid2 = queue[1]["approval_id"] if len(queue) > 1 else None
assert aid2, "Second entry must have an approval_id"
# Respond to the SECOND entry by its approval_id
# We call the handler internals directly (no HTTP)
with r._lock:
queue = r._pending.get(sid, [])
popped = None
for i, entry in enumerate(queue):
if entry.get("approval_id") == aid2:
popped = queue.pop(i)
break
assert popped is not None, "Should have found and popped entry by approval_id"
assert popped["command"] == "cmd2", "Popped the wrong entry"
with r._lock:
remaining = r._pending.get(sid, [])
assert len(remaining) == 1, "One entry should remain after popping the second"
assert remaining[0]["command"] == "cmd1", "The remaining entry should be cmd1"
# Cleanup
with r._lock:
r._pending.pop(sid, None)

View File

@@ -41,7 +41,7 @@ pytestmark = pytest.mark.skipif(
reason="tools.approval not available in this environment"
)
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -0,0 +1,94 @@
"""Regression tests: auth sessions persist across process restarts.
_sessions is an in-memory dict. Without persistence, any restart (launchd,
systemd, container) invalidates all active browser sessions and floods clients
with 401s until they clear cookies. The HMAC signing key already persists to
STATE_DIR; this PR persists the session table using the same pattern.
"""
import importlib
import json
import os
import sys
import tempfile
import time
import unittest
from pathlib import Path
# Isolate state dir so tests never touch real sessions
_TEST_STATE = Path(tempfile.mkdtemp())
os.environ["HERMES_WEBUI_STATE_DIR"] = str(_TEST_STATE)
sys.path.insert(0, str(Path(__file__).parent.parent))
import api.auth as auth
class TestSessionPersistence(unittest.TestCase):
"""Sessions survive a simulated process restart (module reload)."""
def setUp(self) -> None:
auth._sessions.clear()
sessions_file = _TEST_STATE / '.sessions.json'
if sessions_file.exists():
sessions_file.unlink()
def _simulate_restart(self) -> None:
"""Reload auth module to simulate a fresh process start."""
importlib.reload(auth)
def test_session_survives_restart(self) -> None:
"""A session created before restart should still verify after reload."""
cookie = auth.create_session()
self.assertTrue(auth.verify_session(cookie))
self._simulate_restart()
self.assertTrue(auth.verify_session(cookie),
"Session must survive process restart via persisted .sessions.json")
def test_invalidated_session_does_not_survive_restart(self) -> None:
"""Invalidating a session must be reflected after reload."""
cookie = auth.create_session()
auth.invalidate_session(cookie)
self._simulate_restart()
self.assertFalse(auth.verify_session(cookie),
"Invalidated session must not be reinstated after restart")
def test_expired_sessions_pruned_on_load(self) -> None:
"""Sessions that expire between restarts must not be loaded."""
sessions_file = _TEST_STATE / '.sessions.json'
# Write a sessions file with one expired and one valid entry
now = time.time()
sessions_file.write_text(json.dumps({
"expired_token": now - 10,
"valid_token": now + 3600,
}))
self._simulate_restart()
self.assertNotIn("expired_token", auth._sessions)
self.assertIn("valid_token", auth._sessions)
def test_sessions_file_permissions(self) -> None:
"""Sessions file must be owner-read-only (0600)."""
auth.create_session()
sessions_file = _TEST_STATE / '.sessions.json'
self.assertTrue(sessions_file.exists(), ".sessions.json was not created")
mode = oct(sessions_file.stat().st_mode & 0o777)
self.assertEqual(mode, oct(0o600),
f".sessions.json permissions {mode} — expected 0o600")
def test_malformed_sessions_file_starts_fresh(self) -> None:
"""A corrupt sessions file must not crash auth — start with empty dict."""
sessions_file = _TEST_STATE / '.sessions.json'
sessions_file.write_text("not valid json {{{{")
self._simulate_restart()
self.assertEqual(auth._sessions, {},
"Corrupt sessions file must result in empty session dict")
def test_sessions_file_wrong_type_starts_fresh(self) -> None:
"""A sessions file containing a non-dict must be ignored."""
sessions_file = _TEST_STATE / '.sessions.json'
sessions_file.write_text(json.dumps(["list", "not", "dict"]))
self._simulate_restart()
self.assertEqual(auth._sessions, {})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,148 @@
"""Regression tests for the /background task tracker.
Covers two bugs caught in review of PR #932:
1. `get_results()` was calling `_BACKGROUND_TASKS.pop(parent_sid, [])`, which
removed EVERY task (including still-running ones) on the first poll. Once
popped, `complete_background()` could no longer find the task to mark done,
so the final answer was silently lost.
2. The `_handle_background` worker thread called `_run_agent_streaming` but
never invoked `complete_background()` after it returned. With no completion
hook, every background task stayed in `status="running"` forever —
`get_results()` filtered them out of its "done" list, and the user never
saw the result.
These two bugs together made the `/background` command completely
non-functional as originally shipped. The fix in api/background.py +
api/routes.py wires the completion hook and keeps running tasks in the
tracker until they resolve.
"""
from __future__ import annotations
import os
import pathlib
import sys
import time
import unittest
from unittest.mock import patch
# Ensure the repo root is importable without relying on CWD.
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
class TestGetResultsKeepsRunningTasks(unittest.TestCase):
"""get_results() MUST NOT drop still-running tasks from _BACKGROUND_TASKS."""
def setUp(self):
import api.background as bg
bg._BACKGROUND_TASKS.clear()
self.bg = bg
def test_running_tasks_survive_get_results_call(self):
"""A running task must remain in the tracker so complete_background()
can still find it after the first poll returns."""
parent = "parent-session-1"
self.bg.track_background(
parent_sid=parent, bg_sid="bg-a", stream_id="s-a",
task_id="task-a", prompt="long task",
)
# First poll: task is still running, no done results to return
results = self.bg.get_results(parent)
self.assertEqual(results, [], "no done tasks yet — nothing to return")
# The running task MUST still be tracked — otherwise the worker
# thread's complete_background call cannot find it.
remaining = self.bg.get_background_tasks(parent)
self.assertEqual(len(remaining), 1, (
"get_results dropped the still-running task — subsequent "
"complete_background() calls will silently no-op and the "
"result will be lost forever"
))
self.assertEqual(remaining[0]["status"], "running")
self.assertEqual(remaining[0]["task_id"], "task-a")
def test_done_tasks_are_returned_and_removed(self):
"""Done tasks are returned and popped; running tasks stay."""
parent = "parent-session-2"
self.bg.track_background(parent, "bg-done", "s-d", "task-done", "p1")
self.bg.track_background(parent, "bg-run", "s-r", "task-run", "p2")
self.bg.complete_background(parent, "task-done", "42")
results = self.bg.get_results(parent)
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["task_id"], "task-done")
self.assertEqual(results[0]["answer"], "42")
# Done one is gone; running one is still tracked
remaining = self.bg.get_background_tasks(parent)
self.assertEqual(len(remaining), 1)
self.assertEqual(remaining[0]["task_id"], "task-run")
self.assertEqual(remaining[0]["status"], "running")
def test_complete_after_poll_still_reaches_tracker(self):
"""Regression for the original bug: poll → complete → poll must surface
the result. Before the fix, the first poll popped the running task and
complete_background()'s loop iterated over an empty list."""
parent = "parent-session-3"
self.bg.track_background(parent, "bg-x", "s-x", "task-x", "slow task")
# Frontend polls before the task finishes
first = self.bg.get_results(parent)
self.assertEqual(first, [])
# Worker thread finishes and calls complete_background
self.bg.complete_background(parent, "task-x", "answer!")
# Next poll must surface the answer
second = self.bg.get_results(parent)
self.assertEqual(len(second), 1)
self.assertEqual(second[0]["task_id"], "task-x")
self.assertEqual(second[0]["answer"], "answer!")
def test_empty_parent_is_cleaned_up(self):
"""When all tasks are done and returned, the parent key is removed from the dict."""
parent = "parent-session-4"
self.bg.track_background(parent, "bg-1", "s-1", "task-1", "p")
self.bg.complete_background(parent, "task-1", "ok")
self.bg.get_results(parent)
self.assertNotIn(parent, self.bg._BACKGROUND_TASKS)
class TestBackgroundCompletionHookWiring(unittest.TestCase):
"""Static check: the _handle_background worker thread must call
complete_background() after _run_agent_streaming returns. Without this,
running tasks stay forever-running and the user never sees the result.
"""
def test_run_bg_and_notify_calls_complete_background(self):
"""_handle_background must wrap _run_agent_streaming in a function
that subsequently invokes complete_background(parent_sid, task_id, answer)."""
routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
# Locate the _handle_background function
idx = routes_src.find("def _handle_background(")
self.assertGreater(idx, -1, "_handle_background() not found in routes.py")
# Take a generous window around the function body
end = routes_src.find("\ndef ", idx + 1)
body = routes_src[idx:end if end > 0 else idx + 3000]
self.assertIn("complete_background", body, (
"_handle_background worker must call complete_background() after "
"_run_agent_streaming returns — otherwise the tracker never "
"transitions the task to status='done' and /api/background/status "
"returns nothing forever. See api/background.py:complete_background."
))
# Must extract the last assistant message content from the bg session
self.assertIn("_run_agent_streaming", body)
self.assertIn("Session.load", body, (
"_run_bg_and_notify must reload the bg session to extract the "
"final assistant reply so complete_background gets an actual answer"
))
if __name__ == "__main__":
unittest.main()

235
tests/test_batch_fixes.py Normal file
View File

@@ -0,0 +1,235 @@
"""Tests for the batch of fixes from PRs #506-#521 (v0.50.47).
Covers:
- /root workspace unblocking (#510/#521)
- Attached-files split guard (#521)
- custom_providers model visibility (#515/#519)
- Cron skill cache invalidation (#507/#508)
- System (auto) theme (#504/#506/#509/#514)
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
def read(rel):
return (REPO / rel).read_text()
# ── Group A: /root workspace ──────────────────────────────────────────────────
class TestRootWorkspaceUnblocked:
def test_root_not_in_blocked_system_roots(self):
src = read("api/workspace.py")
assert "Path('/root')" not in src, (
"/root must not be in _BLOCKED_SYSTEM_ROOTS — "
"breaks deployments where Hermes runs as root"
)
def test_etc_still_blocked(self):
"""Sanity: other dangerous paths remain blocked."""
src = read("api/workspace.py")
assert "Path('/etc')" in src
assert "Path('/proc')" in src
def test_split_guard_present(self):
src = read("api/streaming.py")
assert "'\\n\\n[Attached files:' in msg_text" in src, (
"base_text split must guard against missing '[Attached files:' "
"to avoid empty-string on plain messages"
)
# ── Group B: custom_providers visibility ─────────────────────────────────────
class TestCustomProvidersVisibility:
def test_has_custom_providers_variable_present(self):
src = read("api/config.py")
assert "_has_custom_providers" in src, (
"_has_custom_providers variable must exist in get_available_models()"
)
def test_discard_custom_conditional_on_no_custom_providers(self):
src = read("api/config.py")
assert "not _has_custom_providers" in src, (
"detected_providers.discard('custom') must be gated on "
"'not _has_custom_providers'"
)
def test_custom_providers_isinstance_check(self):
src = read("api/config.py")
assert "isinstance(_custom_providers_cfg, list)" in src, (
"_has_custom_providers must check isinstance(..., list)"
)
# ── Group C: cron skill cache ─────────────────────────────────────────────────
class TestCronSkillCacheInvalidation:
def _panels_src(self):
return read("static/panels.js")
def test_cache_busted_on_form_open(self):
src = self._panels_src()
# toggleCronForm should set cache to null unconditionally
# openCronCreate() opens the task create form (renamed from toggleCronForm
# in the main-view refactor). It must null the skills cache before fetching.
m = re.search(
r'function openCronCreate\(\)\{.*?_cronSkillsCache\s*=\s*null',
src, re.DOTALL
)
assert m, (
"openCronCreate must unconditionally null _cronSkillsCache "
"before fetching skills"
)
def test_cache_not_guarded_by_if_on_open(self):
src = self._panels_src()
# openCronCreate must not gate the fetch behind an if(!_cronSkillsCache) guard.
m = re.search(
r'function openCronCreate\(\)\{.*?\}',
src, re.DOTALL
)
assert m, "openCronCreate definition not found"
assert "if(!_cronSkillsCache)" not in m.group(0), (
"openCronCreate should not use 'if(!_cronSkillsCache)' guard — "
"cache must always be busted on open"
)
def test_cache_busted_on_skill_save(self):
src = self._panels_src()
# saveSkillForm() is the handler invoked on skill save (renamed from
# submitSkillSave in the main-view refactor; the old name still aliases it).
m = re.search(
r'async function saveSkillForm\(\).*?_skillsData\s*=\s*null.*?_cronSkillsCache\s*=\s*null',
src, re.DOTALL
)
assert m, (
"_cronSkillsCache must be set to null in saveSkillForm() "
"right after _skillsData = null"
)
# ── Group D: System (auto) theme ──────────────────────────────────────────────
class TestSystemTheme:
def test_apply_theme_helper_in_boot_js(self):
src = read("static/boot.js")
assert "function _applyTheme(" in src, (
"_applyTheme helper function must be defined in boot.js"
)
def test_apply_theme_resolves_system(self):
src = read("static/boot.js")
assert "normalized.theme==='system'" in src or "=== 'system'" in src, (
"_applyTheme must branch on 'system' to resolve via matchMedia"
)
def test_apply_theme_uses_matchmedia(self):
src = read("static/boot.js")
assert "prefers-color-scheme" in src, (
"_applyTheme must use matchMedia('(prefers-color-scheme:dark)')"
)
def test_load_settings_calls_apply_theme(self):
src = read("static/boot.js")
assert "_applyTheme(appearance.theme)" in src, (
"loadSettings must call _applyTheme() instead of direct data-theme assignment"
)
def test_system_option_in_theme_picker(self):
html = read("static/index.html")
assert "_pickTheme('system')" in html, (
"Theme picker must include a system theme button"
)
assert ">System<" in html, (
"Theme picker must show 'System' label"
)
def test_theme_picker_uses_pick_theme(self):
html = read("static/index.html")
assert "_pickTheme(" in html, (
"Theme buttons must call _pickTheme()"
)
def test_flicker_script_resolves_system(self):
html = read("static/index.html")
# The head flicker-prevention IIFE must handle 'system'
assert "==='system'" in html or "=== 'system'" in html, (
"Flicker-prevention head script must resolve 'system' before setting data-theme"
)
assert "legacy={slate:['dark','slate']" in html, (
"Flicker-prevention head script must normalize legacy theme names on first paint"
)
def test_system_in_commands_themes_list(self):
src = read("static/commands.js")
assert "'system'" in src, (
"/theme command must include 'system' in the valid themes array"
)
def test_commands_uses_apply_theme(self):
src = read("static/commands.js")
assert "_applyTheme(appearance.theme)" in src, (
"cmdTheme must call _applyTheme() with the normalized canonical theme"
)
def test_commands_accept_legacy_theme_aliases(self):
src = read("static/commands.js")
assert "const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});" in src, (
"cmdTheme must accept legacy theme aliases and map them onto canonical appearance values"
)
def test_panels_reverts_via_apply_theme(self):
src = read("static/panels.js")
assert "_applyTheme(_settingsThemeOnOpen)" in src or \
"_applyTheme(" in src, (
"_revertSettingsPreview must call _applyTheme() so 'system' "
"is correctly re-activated on settings discard"
)
def test_panels_saves_system_string_not_resolved(self):
src = read("static/panels.js")
assert "localStorage.getItem('hermes-theme')" in src, (
"_settingsThemeOnOpen must read from localStorage to preserve "
"the 'system' string, not the resolved 'dark'/'light'"
)
def test_i18n_cmd_theme_includes_system_english(self):
src = read("static/i18n.js")
assert "system/dark/light" in src, (
"English cmd_theme i18n key must include 'system' in the theme list"
)
def test_i18n_cmd_theme_all_locales(self):
src = read("static/i18n.js")
count = src.count("system/dark/light")
assert count >= 5, (
f"cmd_theme description should mention 'system' in all 5 locales; "
f"found {count}"
)
def test_theme_listener_cleanup_uses_stable_handler(self):
src = read("static/boot.js")
assert "_systemThemeMq&&_onSystemThemeChange" in src, (
"_applyTheme must track the active OS-theme listener so it can be removed cleanly"
)
assert "removeEventListener('change',_onSystemThemeChange)" in src, (
"_applyTheme must remove the previous OS-theme listener before adding a new one"
)
def test_panels_hydrates_appearance_before_models_fetch(self):
src = read("static/panels.js")
skin_idx = src.index("const skinVal=(settings.skin||'default').toLowerCase();")
# models is now declared as let models=null before the try block
models_idx = src.index("models=await api('/api/models');")
assert skin_idx < models_idx, (
"loadSettingsPanel must hydrate theme/skin before awaiting /api/models, "
"otherwise a slow model fetch can clobber an in-progress skin selection"
)

View File

@@ -0,0 +1,187 @@
"""
Tests for bootstrap.py .env loading fix (issue #730).
bootstrap.py is the primary documented entry point ("python3 bootstrap.py").
Previously it did not load REPO_ROOT/.env, so HERMES_WEBUI_HOST, HERMES_WEBUI_PORT
etc. were silently ignored when launching without start.sh.
Covers:
1. _load_repo_dotenv() sets env vars from a repo .env file
2. _load_repo_dotenv() ignores commented lines and blank lines
3. _load_repo_dotenv() strips quotes from values
4. _load_repo_dotenv() is a no-op when .env does not exist
5. _load_repo_dotenv() prints a warning (not crash) on unreadable .env
6. _load_repo_dotenv() overwrites existing env vars (shell source semantics)
7. _load_repo_dotenv() handles 'export FOO=bar' prefix
8. _load_repo_dotenv() preserves values containing '='
9. Variables are set unconditionally (not setdefault)
10. Structural: loader is called before DEFAULT_HOST/DEFAULT_PORT
"""
import os
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
REPO_ROOT = Path(__file__).parent.parent
class TestLoadRepoDotenv:
def setup_method(self):
self._saved_env = os.environ.copy()
def teardown_method(self):
os.environ.clear()
os.environ.update(self._saved_env)
def _run(self, tmp_path, env_content: str):
"""Write .env to tmp_path and run _load_repo_dotenv() with that root."""
import bootstrap as bs
(tmp_path / ".env").write_text(env_content, encoding="utf-8")
orig_root = bs.REPO_ROOT
try:
bs.REPO_ROOT = tmp_path
bs._load_repo_dotenv()
finally:
bs.REPO_ROOT = orig_root
def test_sets_env_var_from_dotenv(self, tmp_path):
"""Basic key=value is loaded into os.environ."""
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_sets_port_from_dotenv(self, tmp_path):
"""HERMES_WEBUI_PORT is loaded as a string (caller does int() conversion)."""
self._run(tmp_path, "HERMES_WEBUI_PORT=18787\n")
assert os.environ.get("HERMES_WEBUI_PORT") == "18787"
def test_ignores_comment_lines(self, tmp_path):
"""Lines starting with # are not loaded."""
os.environ.pop("HERMES_WEBUI_HOST", None)
self._run(tmp_path, "# HERMES_WEBUI_HOST=should-be-ignored\n")
assert os.environ.get("HERMES_WEBUI_HOST") is None
def test_ignores_blank_lines(self, tmp_path):
"""Blank lines are silently skipped without error."""
self._run(tmp_path, "\n\nHERMES_WEBUI_PORT=9000\n\n")
assert os.environ.get("HERMES_WEBUI_PORT") == "9000"
def test_strips_double_quoted_values(self, tmp_path):
"""Values wrapped in double quotes are stripped."""
self._run(tmp_path, 'HERMES_WEBUI_HOST="0.0.0.0"\n')
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_strips_single_quoted_values(self, tmp_path):
"""Values wrapped in single quotes are stripped."""
self._run(tmp_path, "HERMES_WEBUI_HOST='0.0.0.0'\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_noop_when_no_dotenv(self, tmp_path):
"""No .env file — function returns silently without error."""
import bootstrap as bs
orig = bs.REPO_ROOT
try:
bs.REPO_ROOT = tmp_path # tmp_path has no .env
bs._load_repo_dotenv() # must not raise
finally:
bs.REPO_ROOT = orig
def test_noop_when_dotenv_unreadable(self, tmp_path, capsys):
"""Unreadable .env prints a warning to stderr — does not crash."""
import bootstrap as bs
env_path = tmp_path / ".env"
env_path.write_text("HERMES_WEBUI_PORT=9999\n")
orig = bs.REPO_ROOT
try:
bs.REPO_ROOT = tmp_path
with patch("pathlib.Path.read_text", side_effect=PermissionError("no access")):
bs._load_repo_dotenv() # must not raise
finally:
bs.REPO_ROOT = orig
captured = capsys.readouterr()
assert "bootstrap" in captured.err.lower() or "warning" in captured.err.lower() or \
"could not load" in captured.err.lower(), (
"_load_repo_dotenv() should print a warning to stderr on read failure"
)
def test_overwrites_existing_env_var(self, tmp_path):
"""Unconditional overwrite matches shell source semantics."""
os.environ["HERMES_WEBUI_HOST"] = "127.0.0.1"
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_does_not_set_empty_values(self, tmp_path):
"""A key whose value is empty after stripping is not set to a non-empty string."""
os.environ.pop("HERMES_EMPTY_KEY", None)
self._run(tmp_path, 'HERMES_EMPTY_KEY=""\n')
# The current implementation sets key to "" (empty string) — verify it is
# not set to a non-empty string, which would be clearly wrong.
val = os.environ.get("HERMES_EMPTY_KEY")
assert val != "something-wrong", "Empty-value key must not be set to a non-empty string"
# Specifically: empty string or absent are both acceptable behaviours.
assert val in (None, ""), f"Unexpected value for empty-quoted key: {val!r}"
def test_multiple_keys_all_loaded(self, tmp_path):
"""Multiple key=value pairs in one file are all loaded."""
content = "HERMES_WEBUI_HOST=0.0.0.0\nHERMES_WEBUI_PORT=18787\n"
self._run(tmp_path, content)
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
assert os.environ.get("HERMES_WEBUI_PORT") == "18787"
def test_value_with_equals_sign_preserved(self, tmp_path):
"""Values containing '=' (e.g. base64) are preserved correctly."""
self._run(tmp_path, "MY_KEY=abc=def==\n")
assert os.environ.get("MY_KEY") == "abc=def=="
def test_export_prefix_stripped(self, tmp_path):
"""'export FOO=bar' lines are parsed correctly — export prefix is stripped."""
self._run(tmp_path, "export HERMES_WEBUI_HOST=0.0.0.0\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0", (
"'export KEY=value' lines must set KEY, not 'export KEY'"
)
# ---------------------------------------------------------------------------
# Structural tests — confirm the fix is in place
# ---------------------------------------------------------------------------
class TestBootstrapStructure:
def test_load_repo_dotenv_function_exists(self):
"""bootstrap.py must export _load_repo_dotenv()."""
import bootstrap as bs
assert callable(getattr(bs, "_load_repo_dotenv", None)), (
"bootstrap.py must define _load_repo_dotenv() so that "
"python3 bootstrap.py loads REPO_ROOT/.env before reading env defaults"
)
def test_dotenv_loaded_before_default_host_port(self):
"""_load_repo_dotenv() call must appear before DEFAULT_HOST/DEFAULT_PORT in source."""
src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8")
load_pos = src.find("_load_repo_dotenv()")
host_pos = src.find("DEFAULT_HOST")
port_pos = src.find("DEFAULT_PORT")
assert load_pos != -1, "_load_repo_dotenv() call not found in bootstrap.py"
assert load_pos < host_pos, (
"_load_repo_dotenv() must be called before DEFAULT_HOST assignment "
"so that HERMES_WEBUI_HOST from .env is picked up"
)
assert load_pos < port_pos, (
"_load_repo_dotenv() must be called before DEFAULT_PORT assignment "
"so that HERMES_WEBUI_PORT from .env is picked up"
)
def test_start_sh_and_bootstrap_equivalent_env_loading(self):
"""start.sh sources .env before bootstrap.py; bootstrap.py must now do the same."""
start_sh = (REPO_ROOT / "start.sh").read_text(encoding="utf-8")
bootstrap_src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8")
# start.sh sources .env
assert "source" in start_sh and ".env" in start_sh, (
"start.sh should still source .env (regression guard)"
)
# bootstrap.py now loads it too
assert "_load_repo_dotenv" in bootstrap_src, (
"bootstrap.py must load .env so direct invocation matches start.sh behaviour"
)

View File

@@ -0,0 +1,189 @@
"""
Bug batch fixes — April 2026.
Covers:
- #594: .app-dialog and .file-rename-input have light theme overrides in style.css
- #576: workspace panel localStorage restore is gated on session.workspace presence (boot.js)
- #585: get_available_models() calls reload_config() before reading config cache
- #567: docker-compose.yml comment mentions macOS UID mismatch
- #590: _transcribeBlob already calls setComposerStatus('Transcribing…') — confirmed present
"""
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
COMPOSE = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
# ── #594: light theme dialog overrides ───────────────────────────────────────
def test_594_app_dialog_has_light_mode_override():
"""style.css must have a light mode rule targeting .app-dialog background."""
assert ':root:not(.dark) .app-dialog{' in STYLE_CSS, (
"Missing light mode override for .app-dialog — dialogs appear dark on light theme"
)
def test_594_app_dialog_input_has_light_mode_override():
"""style.css must have a light mode rule for .app-dialog-input."""
assert ":root:not(.dark) .app-dialog-input{" in STYLE_CSS, (
"Missing light mode override for .app-dialog-input"
)
def test_594_app_dialog_btn_has_light_mode_override():
"""style.css must have a light mode rule for .app-dialog-btn."""
assert ":root:not(.dark) .app-dialog-btn{" in STYLE_CSS, (
"Missing light mode override for .app-dialog-btn"
)
def test_594_app_dialog_close_has_light_mode_override():
"""style.css must have a light mode rule for .app-dialog-close."""
assert ":root:not(.dark) .app-dialog-close{" in STYLE_CSS, (
"Missing light mode override for .app-dialog-close"
)
def test_594_file_rename_input_has_light_mode_override():
"""style.css must have a light mode rule for .file-rename-input."""
assert ":root:not(.dark) .file-rename-input{" in STYLE_CSS, (
"Missing light mode override for .file-rename-input"
)
# ── dark-mode user bubble semantics ──────────────────────────────────────────
def test_dark_user_bubbles_use_dark_tinted_surface():
"""Dark mode should keep user bubbles dark, with skin only tinting the bubble."""
assert "--user-bubble-bg: var(--accent-bg-strong);" in STYLE_CSS, (
"Dark mode user bubbles should use the dark accent tint, not the full bright accent fill"
)
assert "--user-bubble-border: var(--accent-bg-strong);" in STYLE_CSS, (
"Dark mode user bubble borders should match the quieter thinking-card border intensity"
)
assert "--user-bubble-text: var(--text);" in STYLE_CSS, (
"Dark mode user bubble text should inherit the theme text color"
)
def test_dark_user_bubbles_do_not_need_per_skin_text_hacks():
"""Dark-mode user bubble contrast should not rely on per-skin text overrides."""
assert re.search(r':root\.dark\[data-skin="[^"]+"\]\s*\{\s*--user-bubble-text:', STYLE_CSS) is None, (
"Dark-mode user bubble contrast should come from shared theme tokens, not per-skin text hacks"
)
def test_user_bubbles_define_selection_tokens_for_both_modes():
"""User bubbles need dedicated selection colors so selected text remains readable."""
assert "--user-selection-bg: rgba(0,0,0,.22);" in STYLE_CSS, (
"Light-mode user bubbles should define a darker selection fill for contrast"
)
assert "--user-selection-bg: rgba(255,255,255,.18);" in STYLE_CSS, (
"Dark-mode user bubbles should define a lighter selection fill for contrast"
)
assert "--user-selection-text: #fff;" in STYLE_CSS, (
"Light-mode user bubble selection should preserve readable text color"
)
def test_user_bubble_selection_is_scoped_to_user_message_body():
"""Selection override must apply only to user bubbles, including nested markdown nodes."""
assert '.msg-row[data-role="user"] .msg-body::selection,' in STYLE_CSS, (
"Missing selection override on the user message bubble"
)
assert '.msg-row[data-role="user"] .msg-body *::selection {' in STYLE_CSS, (
"Nested elements inside user messages must inherit the same selection colors"
)
# ── #576: workspace panel snap fix ───────────────────────────────────────────
def test_576_panel_restore_gated_on_workspace():
"""boot.js: localStorage panel restore must be gated on session.workspace."""
# The guard must appear: session.workspace check before _workspacePanelMode='browse'
# Panel pref key takes priority over runtime key (toolbar close must not clear preference)
assert "S.session&&S.session.workspace&&panelPref" in BOOT_JS, (
"Workspace panel localStorage restore must be gated on S.session.workspace "
"to prevent snap-open-then-closed on sessions without a workspace (#576)"
)
assert "'hermes-webui-workspace-panel-pref'" in BOOT_JS, (
"Panel restore must check the preference key so toolbar close does not clear it"
)
def test_576_restore_happens_after_load_session():
"""boot.js: loadSession() must come before the panel restore guard."""
load_pos = BOOT_JS.find("await loadSession(saved)")
restore_pos = BOOT_JS.find("panelPref")
assert load_pos != -1, "loadSession call not found in boot.js"
assert restore_pos != -1, "workspace panel restore guard not found"
assert load_pos < restore_pos, (
"loadSession() must run before the panel restore guard "
"so S.session.workspace is known at restore time"
)
# ── #585: get_available_models reloads config ─────────────────────────────────
def test_585_get_available_models_calls_reload_config():
"""api/config.py: get_available_models() must do a mtime-based reload check."""
config_src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
fn_start = config_src.find("def get_available_models()")
assert fn_start != -1, "get_available_models not found"
fn_body_end = config_src.find('"""', config_src.find('"""', fn_start + 30) + 3) + 3
# Must check mtime before reading config
mtime_pos = config_src.find("_current_mtime", fn_body_end)
active_prov_pos = config_src.find("active_provider = None", fn_body_end)
assert mtime_pos != -1, (
"get_available_models() must check config file mtime before reading cache (#585)"
)
assert mtime_pos < active_prov_pos, (
"mtime check must come before active_provider = None in get_available_models()"
)
# ── #567: docker-compose UID note ─────────────────────────────────────────────
def test_567_compose_mentions_macos_uid():
"""docker-compose.yml must mention macOS UID / id -u to help macOS users."""
assert "macOS" in COMPOSE or "macos" in COMPOSE.lower(), (
"docker-compose.yml should mention macOS UID issue (#567)"
)
assert "id -u" in COMPOSE, (
"docker-compose.yml should tell users to run 'id -u' to find their UID (#567)"
)
# ── #590: transcription spinner already present ───────────────────────────────
def test_590_transcribing_status_shown_before_fetch():
"""boot.js: setComposerStatus('Transcribing…') must fire before the fetch call."""
transcribe_fn_start = BOOT_JS.find("async function _transcribeBlob(")
assert transcribe_fn_start != -1, "_transcribeBlob not found in boot.js"
fn_body = BOOT_JS[transcribe_fn_start:transcribe_fn_start + 600]
status_pos = fn_body.find("setComposerStatus('Transcribing")
fetch_pos = fn_body.find("await fetch(")
assert status_pos != -1, (
"setComposerStatus('Transcribing…') must be called before the fetch in _transcribeBlob"
)
assert fetch_pos != -1, "await fetch not found in _transcribeBlob"
assert status_pos < fetch_pos, (
"setComposerStatus('Transcribing…') must appear before 'await fetch' "
"so the UI shows a spinner immediately on stop (#590)"
)
def test_590_recording_stops_before_transcribe():
"""boot.js: _setRecording(false) must fire in onstop before _transcribeBlob."""
onstop_start = BOOT_JS.find("mediaRecorder.onstop")
assert onstop_start != -1, "mediaRecorder.onstop not found"
onstop_body = BOOT_JS[onstop_start:onstop_start + 400]
rec_pos = onstop_body.find("_setRecording(false)")
blob_pos = onstop_body.find("_transcribeBlob(")
assert rec_pos != -1 and blob_pos != -1
assert rec_pos < blob_pos, (
"_setRecording(false) must come before _transcribeBlob so mic icon clears immediately"
)

View File

@@ -0,0 +1,381 @@
"""Tests for #815 — BYOK/custom provider models missing from WebUI model dropdown.
Root causes fixed:
1. active_provider alias not normalized in get_available_models()
('z.ai' -> 'zai', 'x.ai' -> 'xai', 'google' -> 'gemini', etc.)
causing the provider to fall to the 'else/unknown' branch with no models.
2. /api/models/live didn't normalize the provider query param, so
provider_model_ids() received the un-aliased form and returned [].
3. /api/models/live returned empty for provider='custom' even when
custom_providers entries exist in config.yaml — the live enrichment
step never added those models.
"""
import pathlib
import re
import sys
import unittest.mock as mock
import pytest
REPO = pathlib.Path(__file__).parent.parent
sys.path.insert(0, str(REPO))
sys.path.insert(0, str(REPO.parent / ".hermes" / "hermes-agent"))
def read(rel):
return (REPO / rel).read_text(encoding="utf-8")
@pytest.fixture(autouse=True)
def _isolate_models_cache():
"""Invalidate the TTL model cache before AND after every test.
``get_available_models()`` caches its result keyed on config.yaml mtime.
Tests in this file repoint ``_get_config_path`` to a tmp_path, populate
the cache there, then let monkeypatch restore the original path. The
cache, keyed on the tmp_path's mtime, then poisons downstream tests
(e.g. test_model_resolver) which see stale data and never hit their
mocks. Clearing the cache around each test breaks that linkage.
"""
import api.config as c
try:
c.invalidate_models_cache()
except Exception:
pass
yield
try:
c.invalidate_models_cache()
except Exception:
pass
# ── api/config.py — active_provider normalization ─────────────────────────────
class TestActiveProviderNormalization:
"""get_available_models() must normalize active_provider aliases before lookup."""
def _run(self, tmp_path, provider_str, monkeypatch):
"""Return get_available_models() output for a given provider string."""
import api.config as c
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
f"model:\n provider: {provider_str}\n default: test-model\n",
encoding="utf-8",
)
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
c.reload_config()
# Patch list_available_providers to avoid real network calls
fake_prov = mock.MagicMock()
fake_prov.return_value = []
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "list_available_providers", fake_prov)
except Exception:
pass
result = c.get_available_models()
c.reload_config()
return result
def test_z_dot_ai_normalized_to_zai(self, tmp_path, monkeypatch):
result = self._run(tmp_path, "z.ai", monkeypatch)
# active_provider returned to browser must be canonical 'zai' or
# at minimum must not be 'z.ai' (which would miss the _PROVIDER_MODELS lookup)
ap = result.get("active_provider", "")
assert ap in ("zai", ""), f"active_provider should be 'zai', got {ap!r}"
def test_x_dot_ai_normalized_to_xai(self, tmp_path, monkeypatch):
result = self._run(tmp_path, "x.ai", monkeypatch)
ap = result.get("active_provider", "")
assert ap in ("xai", ""), f"active_provider should be 'xai', got {ap!r}"
def test_google_normalized_to_gemini(self, tmp_path, monkeypatch):
result = self._run(tmp_path, "google", monkeypatch)
ap = result.get("active_provider", "")
assert ap in ("gemini", ""), f"active_provider should be 'gemini', got {ap!r}"
def test_normalization_code_present(self):
"""Source-level check: config.py must call _PROVIDER_ALIASES for active_provider."""
src = read("api/config.py")
# Must alias-normalize active_provider before the group-builder runs
assert "_PROVIDER_ALIASES" in src, (
"api/config.py must import _PROVIDER_ALIASES to normalize active_provider"
)
# The normalization must happen before the group builder loop
alias_pos = src.index("_PROVIDER_ALIASES")
group_builder_pos = src.index("for pid in sorted(detected_providers)")
assert alias_pos < group_builder_pos, (
"active_provider normalization must occur before the group-builder loop"
)
# ── api/routes.py — /api/models/live provider normalization ───────────────────
class TestLiveModelsProviderNormalization:
"""_handle_live_models must normalize the provider query param."""
def test_live_models_normalizes_provider_alias(self):
src = read("api/routes.py")
# Find _handle_live_models function
m = re.search(
r"def _handle_live_models\(.*?\ndef ",
src,
re.DOTALL,
)
assert m, "_handle_live_models not found"
fn = m.group(0)
assert "_resolve_provider_alias" in fn, (
"_handle_live_models must normalize provider via "
"api.config._resolve_provider_alias so 'z.ai' -> 'zai' "
"before calling provider_model_ids()"
)
def test_live_models_normalization_before_provider_model_ids(self):
"""Normalization call must appear before the provider_model_ids call site."""
src = read("api/routes.py")
alias_match = re.search(
r"provider\s*=\s*_resolve_provider_alias\(provider\)",
src,
)
pmi_call_match = re.search(
r"ids\s*=\s*_pmi\(provider\)",
src,
)
assert alias_match, "_resolve_provider_alias call not found in routes.py"
assert pmi_call_match, "ids = _pmi(provider) call not found"
assert alias_match.start() < pmi_call_match.start(), (
"alias normalization must occur before ids = _pmi(provider)"
)
def test_alias_resolver_works_without_hermes_cli(self):
"""Normalization must work even when hermes_cli is not importable —
CI and installs without the agent cloned alongside the WebUI.
The WebUI ships its own _PROVIDER_ALIASES table; the agent's table
is merged only when available."""
import api.config as c
# Core CLI aliases from #815's bug report
assert c._resolve_provider_alias('z.ai') == 'zai'
assert c._resolve_provider_alias('x.ai') == 'xai'
assert c._resolve_provider_alias('google') == 'gemini'
assert c._resolve_provider_alias('grok') == 'xai'
# Case / whitespace insensitive
assert c._resolve_provider_alias(' Z.AI ') == 'zai'
# Canonical names pass through unchanged
assert c._resolve_provider_alias('openrouter') == 'openrouter'
assert c._resolve_provider_alias('anthropic') == 'anthropic'
assert c._resolve_provider_alias('custom') == 'custom'
# Empty / None pass through
assert c._resolve_provider_alias('') == ''
assert c._resolve_provider_alias(None) is None
# ── api/routes.py — /api/models/live custom_providers fallback ────────────────
class TestLiveModelsCustomProviderFallback:
"""When provider='custom' and provider_model_ids() returns [],
/api/models/live must fall back to custom_providers entries from config.yaml."""
def test_custom_fallback_code_present(self):
src = read("api/routes.py")
m = re.search(
r"def _handle_live_models\(.*?\ndef ",
src,
re.DOTALL,
)
assert m, "_handle_live_models not found"
fn = m.group(0)
assert "custom_providers" in fn, (
"_handle_live_models must read custom_providers from config "
"as fallback when provider='custom' and provider_model_ids() returns []"
)
assert 'provider == "custom"' in fn or "provider=='custom'" in fn, (
"_handle_live_models must check provider == 'custom' before fallback"
)
def test_custom_fallback_returns_configured_models(self, tmp_path, monkeypatch):
"""End-to-end: /api/models/live?provider=custom returns custom_providers models."""
import api.config as c
import api.routes as r
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
"model:\n provider: custom\n default: my-byok-model\n"
"custom_providers:\n"
" - model: my-byok-model\n"
" api_base: https://my-llm.example.com/v1\n"
" api_key: sk-test\n",
encoding="utf-8",
)
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
c.reload_config()
# Mock handler and parsed URL
handler = mock.MagicMock()
responses = []
def fake_j(h, data, **kw):
responses.append(data)
return True
monkeypatch.setattr(r, "j", fake_j)
from urllib.parse import urlparse
parsed = mock.MagicMock()
parsed.query = "provider=custom"
# Mock provider_model_ids to return [] (simulating no live endpoint)
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "provider_model_ids", lambda p: [])
except Exception:
pass
r._handle_live_models(handler, parsed)
assert responses, "handler must produce a response"
resp = responses[-1]
assert "models" in resp
model_ids = [m["id"] for m in resp.get("models", [])]
assert "my-byok-model" in model_ids, (
f"custom_providers model 'my-byok-model' must appear in live response; "
f"got {model_ids}"
)
# ── Regression: known-good providers still work ───────────────────────────────
class TestKnownProvidersUnaffected:
"""Normalization must not break providers whose names are already canonical."""
def test_openrouter_unaffected(self):
src = read("api/config.py")
# _PROVIDER_ALIASES lookup: 'openrouter' -> 'openrouter' (no change)
assert "openrouter" in src, "openrouter must still exist in config"
def test_anthropic_unaffected(self):
src = read("api/config.py")
assert "anthropic" in src
def test_custom_unaffected(self):
"""'custom' is not in _PROVIDER_ALIASES so normalization is a no-op."""
try:
from hermes_cli.models import _PROVIDER_ALIASES
assert "custom" not in _PROVIDER_ALIASES, (
"'custom' must not be aliased to anything — it's a special sentinel"
)
except ImportError:
pass # hermes-agent not available in this env — skip
# ── Source-level: active_provider returned to browser is canonical ─────────────
class TestProviderIdInGroupResponse:
"""get_available_models() must include provider_id on every group so the JS
_fetchLiveModels can match optgroups exactly rather than by substring."""
def test_groups_include_provider_id(self, tmp_path, monkeypatch):
import api.config as c
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
"model:\n provider: zai\n default: glm-5\n",
encoding="utf-8",
)
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
c.reload_config()
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "list_available_providers", lambda: [
{"id": "zai", "authenticated": True}
])
import hermes_cli.auth as ha
monkeypatch.setattr(ha, "get_auth_status", lambda p: {"key_source": "env"})
except Exception:
pass
result = c.get_available_models()
c.reload_config()
for g in result.get("groups", []):
assert "provider_id" in g, (
f"group {g.get('provider')!r} missing provider_id — "
"JS _fetchLiveModels needs it to match optgroups exactly"
)
def test_provider_id_in_static_ui_js_optgroup(self):
src = read("static/ui.js")
assert "og.dataset.provider" in src, (
"populateModelDropdown must set og.dataset.provider from g.provider_id "
"so _fetchLiveModels can match by exact provider_id"
)
def test_fetch_live_models_prefers_data_provider_match(self):
src = read("static/ui.js")
# Live model optgroup matching was extracted to _addLiveModelsToSelect (#872)
m = re.search(r'function _addLiveModelsToSelect\b.*?\n\}', src, re.DOTALL)
if not m:
m = re.search(r'function _fetchLiveModels\b.*?\n\}', src, re.DOTALL)
assert m, "_addLiveModelsToSelect or _fetchLiveModels not found"
fn = m.group(0)
assert 'og.dataset.provider' in fn, (
"_addLiveModelsToSelect must check og.dataset.provider===provider before "
"falling back to label substring match"
)
# The data-provider check must come before the label.includes check
dp_pos = fn.index('og.dataset.provider')
label_pos = fn.index('og.label')
assert dp_pos < label_pos, (
"data-provider exact match must be attempted before label substring match"
)
# ── Opus-identified edge case: 'ollama' normalizes to 'custom' ────────────────
class TestOllamaAliasEdgeCase:
"""Opus review found: 'ollama' -> 'custom' via _PROVIDER_ALIASES.
This is better behaviour (custom_providers fallback catches it) but worth
documenting and not regressing."""
def test_ollama_not_in_provider_aliases_as_ollama(self):
"""'ollama' maps to 'custom' in _PROVIDER_ALIASES — verify this is the
intended behavior post-normalization (not a silent breakage)."""
try:
from hermes_cli.models import _PROVIDER_ALIASES
# 'ollama' -> 'custom' means ollama users hit the custom_providers path
# This is fine — ollama models appear via base_url auto-detection (step 3)
# in get_available_models, not via _PROVIDER_MODELS lookup.
ollama_target = _PROVIDER_ALIASES.get("ollama", "ollama")
# Acceptable outcomes: either unchanged (not in aliases) or 'custom'/'ollama-cloud'
assert ollama_target in ("ollama", "custom", "ollama-cloud"), (
f"Unexpected ollama alias: {ollama_target}"
)
except ImportError:
pass # hermes-agent not available
class TestGetAvailableModelsReturnsCanonicalProvider:
"""get_available_models() must return normalized active_provider in its response
so the browser sends the right value to /api/models/live."""
def test_active_provider_in_response_is_normalized(self, tmp_path, monkeypatch):
import api.config as c
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
"model:\n provider: z.ai\n default: glm-5\n",
encoding="utf-8",
)
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
c.reload_config()
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "list_available_providers", lambda: [])
except Exception:
pass
result = c.get_available_models()
c.reload_config()
ap = result.get("active_provider", "")
# The browser will pass this value to /api/models/live?provider=<ap>
# It must be 'zai' so optgroup matching works in _fetchLiveModels
assert ap != "z.ai", (
"active_provider 'z.ai' must be normalized to 'zai' before being "
"returned to the browser (browser passes it back to /api/models/live)"
)

View File

@@ -43,7 +43,10 @@ class TestCancelInterrupt:
# Assert
assert result is True
mock_agent.interrupt.assert_called_once_with("Cancelled by user")
assert CANCEL_FLAGS[stream_id].is_set()
# CANCEL_FLAGS is eagerly popped after cancel (#776 fix) so the flag
# is no longer in the dict — verify the pop happened instead
assert stream_id not in CANCEL_FLAGS, \
"cancel_stream() should eagerly pop CANCEL_FLAGS after signalling"
def test_cancel_handles_interrupt_exception(self):
"""Verify that cancel_stream() handles interrupt() exceptions gracefully"""
@@ -61,7 +64,8 @@ class TestCancelInterrupt:
# Assert
assert result is True
mock_agent.interrupt.assert_called_once()
assert CANCEL_FLAGS[stream_id].is_set()
assert stream_id not in CANCEL_FLAGS, \
"cancel_stream() should eagerly pop CANCEL_FLAGS even on interrupt exception"
def test_cancel_before_agent_ready(self):
"""Test cancel when agent not yet stored in AGENT_INSTANCES (race condition)"""
@@ -76,8 +80,11 @@ class TestCancelInterrupt:
# Assert
assert result is True
assert CANCEL_FLAGS[stream_id].is_set()
# Agent will check this flag when it starts
# CANCEL_FLAGS is eagerly popped; the agent thread checks the event
# object it already has a reference to — pop doesn't clear the event
assert stream_id not in CANCEL_FLAGS, \
"cancel_stream() should eagerly pop CANCEL_FLAGS even without an agent"
# Agent will check this flag (it holds a reference to the event object)
def test_cancel_nonexistent_stream(self):
"""Test cancel for a stream that doesn't exist"""

View File

@@ -0,0 +1,111 @@
from collections import Counter
from pathlib import Path
import re
REPO = Path(__file__).resolve().parent.parent
def read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def extract_locale_block(src: str, locale_key: str) -> str:
start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src)
assert start_match, f"{locale_key} locale block not found"
start = start_match.end() - 1 # "{"
depth = 0
in_single = False
in_double = False
in_backtick = False
escape = False
for i in range(start, len(src)):
ch = src[i]
if escape:
escape = False
continue
if in_single:
if ch == "\\":
escape = True
elif ch == "'":
in_single = False
continue
if in_double:
if ch == "\\":
escape = True
elif ch == '"':
in_double = False
continue
if in_backtick:
if ch == "\\":
escape = True
elif ch == "`":
in_backtick = False
continue
if ch == "'":
in_single = True
continue
if ch == '"':
in_double = True
continue
if ch == "`":
in_backtick = True
continue
if ch == "{":
depth += 1
continue
if ch == "}":
depth -= 1
if depth == 0:
return src[start + 1 : i]
raise AssertionError(f"{locale_key} locale block braces are not balanced")
def test_chinese_locale_block_exists():
src = read(REPO / "static" / "i18n.js")
assert "\n zh: {" in src
assert "_lang: 'zh'" in src
assert "_speech: 'zh-CN'" in src
def test_chinese_locale_includes_representative_translations():
src = read(REPO / "static" / "i18n.js")
expected = [
"settings_title: '\\u8bbe\\u7f6e'",
"login_title: '\\u767b\\u5f55'",
"approval_heading: '需要审批'",
"tab_tasks: '任务'",
"tab_profiles: '配置'",
"session_time_just_now: '刚刚'",
"onboarding_title: '欢迎使用 Hermes Web UI'",
"onboarding_complete: '引导完成'",
]
for entry in expected:
assert entry in src
def test_chinese_locale_covers_english_keys():
src = read(REPO / "static" / "i18n.js")
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
en_keys = set(key_pattern.findall(extract_locale_block(src, "en")))
zh_keys = set(key_pattern.findall(extract_locale_block(src, "zh")))
missing = sorted(en_keys - zh_keys)
assert not missing, f"Chinese locale missing keys: {missing}"
def test_chinese_locale_has_no_duplicate_keys():
src = read(REPO / "static" / "i18n.js")
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
keys = key_pattern.findall(extract_locale_block(src, "zh"))
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
assert not duplicates, f"Chinese locale has duplicate keys: {duplicates}"

View File

@@ -0,0 +1,165 @@
"""Tests for clarify prompt unblocking and HTTP endpoints."""
import json
import threading
import uuid
import urllib.request
import urllib.error
import urllib.parse
import pytest
try:
from api.clarify import (
register_gateway_notify,
unregister_gateway_notify,
resolve_clarify,
clear_pending,
_gateway_queues,
_gateway_notify_cbs,
_lock,
_ClarifyEntry,
submit_pending,
)
CLARIFY_AVAILABLE = True
except ImportError:
CLARIFY_AVAILABLE = False
pytestmark = pytest.mark.skipif(
not CLARIFY_AVAILABLE,
reason="api.clarify not available in this environment",
)
from tests._pytest_port import BASE
def get(path):
url = BASE + path
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())
def post(path, body=None):
url = BASE + path
data = json.dumps(body or {}).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
class TestClarifyUnblocking:
"""Unit tests for clarify queue resolution."""
def test_resolve_clarify_sets_event(self):
sid = f"unit-clarify-{uuid.uuid4().hex[:8]}"
entry = _ClarifyEntry({"question": "Pick one", "choices_offered": ["a", "b"]})
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
resolved = resolve_clarify(sid, "a", resolve_all=False)
assert resolved == 1
assert entry.event.is_set()
assert entry.result == "a"
def test_register_and_fire_notify_cb(self):
sid = f"unit-notify-{uuid.uuid4().hex[:8]}"
fired = []
register_gateway_notify(sid, lambda d: fired.append(d))
with _lock:
cb = _gateway_notify_cbs.get(sid)
assert cb is not None
data = {"question": "What now?", "choices_offered": ["x", "y"]}
cb(data)
assert fired == [data]
unregister_gateway_notify(sid)
def test_clear_pending_unblocks_waiters(self):
sid = f"unit-clear-{uuid.uuid4().hex[:8]}"
entry = _ClarifyEntry({"question": "Wait", "choices_offered": []})
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
cleared = clear_pending(sid)
assert cleared == 1
assert entry.event.is_set()
with _lock:
assert sid not in _gateway_queues
def test_submit_pending_registers_entry(self):
sid = f"unit-submit-{uuid.uuid4().hex[:8]}"
data = {"question": "Pick", "choices_offered": ["one", "two"], "session_id": sid}
entry = submit_pending(sid, data)
assert entry.data == data
with _lock:
assert sid in _gateway_queues
clear_pending(sid)
class TestClarifyModuleExports:
def test_register_gateway_notify_exported(self):
import api.clarify as ap
assert hasattr(ap, "register_gateway_notify")
def test_unregister_gateway_notify_exported(self):
import api.clarify as ap
assert hasattr(ap, "unregister_gateway_notify")
def test_resolve_clarify_exported(self):
import api.clarify as ap
assert hasattr(ap, "resolve_clarify")
def test_clarify_entry_exported(self):
import api.clarify as ap
assert hasattr(ap, "_ClarifyEntry")
class TestClarifyHTTPEndpoints:
"""Regression tests for /api/clarify/respond against the live test server."""
def test_respond_returns_ok_no_pending(self):
sid = f"http-no-pending-{uuid.uuid4().hex[:8]}"
result, status = post("/api/clarify/respond", {
"session_id": sid,
"response": "Use option A",
})
assert status == 200
assert result["ok"] is True
def test_respond_requires_session_id(self):
result, status = post("/api/clarify/respond", {"response": "Hello"})
assert status == 400
def test_respond_requires_response(self):
sid = f"http-no-response-{uuid.uuid4().hex[:8]}"
result, status = post("/api/clarify/respond", {"session_id": sid})
assert status == 400
def test_respond_clears_injected_pending(self):
sid = f"http-clear-{uuid.uuid4().hex[:8]}"
question = urllib.parse.quote("Pick the better option")
choices = urllib.parse.quote("A")
inject = get(
f"/api/clarify/inject_test?session_id={urllib.parse.quote(sid)}"
f"&question={question}&choices={choices}"
)
assert inject["ok"] is True
data = get(f"/api/clarify/pending?session_id={urllib.parse.quote(sid)}")
assert data["pending"] is not None
result, status = post("/api/clarify/respond", {
"session_id": sid,
"response": "B",
})
assert status == 200
assert result["ok"] is True
data2 = get(f"/api/clarify/pending?session_id={urllib.parse.quote(sid)}")
assert data2["pending"] is None

View File

@@ -0,0 +1,69 @@
"""Tests for #838 — slash command dropdown keyboard navigation keeps the
selected item in view."""
import os
import re
_SRC = os.path.join(os.path.dirname(__file__), "..")
def _read(name):
return open(os.path.join(_SRC, name), encoding="utf-8").read()
class TestNavigateCmdDropdownScroll:
"""navigateCmdDropdown must scroll the newly selected item into view so
keyboard navigation on a long list doesn't leave the highlight below the
visible area of the dropdown."""
def test_navigate_calls_scroll_into_view(self):
js = _read("static/commands.js")
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
assert m, "navigateCmdDropdown not found"
fn = m.group(0)
assert 'scrollIntoView' in fn, (
"navigateCmdDropdown must call scrollIntoView on the newly "
"selected item so ↓/↑ keeps the highlight visible (#838)"
)
def test_scroll_uses_nearest_block_alignment(self):
"""`{block:'nearest'}` is the correct option: scrolls only when
needed, minimum distance — won't jump the list around on every
arrow-key press when the item is already in view."""
js = _read("static/commands.js")
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
assert m
fn = m.group(0)
assert "block:'nearest'" in fn or 'block: "nearest"' in fn, (
"scrollIntoView should use {block:'nearest'} to scroll the "
"minimum amount needed"
)
def test_scroll_after_selected_class_update(self):
"""The scroll call must come AFTER adding the .selected class so
the correct item is targeted."""
js = _read("static/commands.js")
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
assert m
fn = m.group(0)
selected_pos = fn.find("classList.add('selected')")
scroll_pos = fn.find("scrollIntoView")
assert selected_pos != -1 and scroll_pos != -1
assert selected_pos < scroll_pos, (
"scrollIntoView must run after classList.add('selected') so it "
"scrolls the newly-highlighted item into view"
)
def test_cmd_dropdown_is_scroll_container(self):
"""Regression guard: the .cmd-dropdown must have overflow-y:auto
(or similar) so scrollIntoView finds it as the scroll ancestor
rather than bubbling up to the viewport."""
css = _read("static/style.css")
m = re.search(r'\.cmd-dropdown\s*\{[^}]+\}', css)
assert m, ".cmd-dropdown rule not found"
block = m.group(0)
assert 'overflow-y:auto' in block or 'overflow-y: auto' in block or \
'overflow:auto' in block or 'overflow: auto' in block, (
".cmd-dropdown must have overflow-y:auto so scrollIntoView "
"scrolls within the dropdown, not the whole page"
)

View File

@@ -0,0 +1,84 @@
"""Tests for GET /api/commands -- exposes hermes-agent COMMAND_REGISTRY."""
import json
import urllib.request
import pytest
from tests.conftest import TEST_BASE, requires_agent_modules
def _get(path):
"""GET helper -- returns parsed JSON or raises HTTPError."""
with urllib.request.urlopen(TEST_BASE + path, timeout=10) as r:
return json.loads(r.read())
@requires_agent_modules
def test_commands_endpoint_returns_list():
"""GET /api/commands returns a JSON object with a 'commands' list."""
body = _get('/api/commands')
assert 'commands' in body
assert isinstance(body['commands'], list)
assert len(body['commands']) > 0
@requires_agent_modules
def test_commands_endpoint_includes_help():
"""The 'help' command must always be present (it's not cli_only)."""
body = _get('/api/commands')
names = {c['name'] for c in body['commands']}
assert 'help' in names
@requires_agent_modules
def test_commands_endpoint_command_shape():
"""Each command entry has the required fields."""
body = _get('/api/commands')
cmd = next(c for c in body['commands'] if c['name'] == 'help')
required = {
'name', 'description', 'category', 'aliases',
'args_hint', 'subcommands', 'cli_only', 'gateway_only',
}
assert set(cmd.keys()) >= required
assert isinstance(cmd['aliases'], list)
assert isinstance(cmd['subcommands'], list)
assert isinstance(cmd['cli_only'], bool)
assert isinstance(cmd['gateway_only'], bool)
@requires_agent_modules
def test_commands_endpoint_excludes_gateway_only_and_never_expose():
"""gateway_only commands and the _NEVER_EXPOSE set are filtered out."""
body = _get('/api/commands')
names = {c['name'] for c in body['commands']}
# /sethome, /restart, /update are gateway_only; /commands is in _NEVER_EXPOSE
for name in ('sethome', 'restart', 'update', 'commands'):
assert name not in names, f"{name} must be excluded from /api/commands"
@requires_agent_modules
def test_commands_endpoint_keeps_new_with_reset_alias():
"""The 'new' command stays exposed and carries its 'reset' alias."""
body = _get('/api/commands')
new_cmd = next(c for c in body['commands'] if c['name'] == 'new')
assert 'reset' in new_cmd['aliases']
def test_list_commands_returns_empty_for_empty_registry():
"""list_commands(_registry=[]) returns [] -- the same path as when
hermes_cli is missing (the empty-or-missing case)."""
from api.commands import list_commands
assert list_commands(_registry=[]) == []
def test_list_commands_degrades_when_agent_missing(monkeypatch):
"""If hermes_cli.commands is not importable, list_commands() returns []
via the ImportError path. Verified by stubbing sys.modules; test cleanup
is handled by monkeypatch + the fact that we don't reload api.commands."""
import sys
monkeypatch.setitem(sys.modules, 'hermes_cli.commands', None)
# NOTE: we do NOT reload api.commands. The lazy import inside
# list_commands() will re-attempt the import on each call and hit
# the stubbed-None module, raising ImportError, taking the fallback path.
from api.commands import list_commands
assert list_commands() == []

View File

@@ -0,0 +1,595 @@
"""Regression tests for credential_pool provider detection in /api/models."""
import json
import sys
import types
import api.config as config
import api.profiles as profiles
_AMBIENT_SOURCES = {"gh_cli", "gh auth token"}
def _install_fake_hermes_cli(monkeypatch, *, with_load_pool: bool = False, pool_data: dict | None = None):
"""Stub hermes_cli modules so tests are deterministic and offline.
When *with_load_pool* is True, also stubs hermes_cli.credential_pool with a
suppression-aware load_pool() implementation that mirrors upstream behaviour:
entries whose source/label/key_source signals ambient gh-cli auth are filtered out.
"""
fake_pkg = types.ModuleType("hermes_cli")
fake_pkg.__path__ = []
fake_models = types.ModuleType("hermes_cli.models")
fake_models.list_available_providers = lambda: []
fake_models.provider_model_ids = lambda pid: (
["gpt-oss:20b", "qwen3:30b-a3b"] if pid == "ollama-cloud" else []
)
fake_auth = types.ModuleType("hermes_cli.auth")
fake_auth.get_auth_status = lambda _pid: {}
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
# Always remove the real agent.credential_pool so get_available_models() takes
# the ImportError fallback path and reads from the monkeypatched auth store,
# not the live ~/.hermes/auth.json via the real venv module.
monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
monkeypatch.delitem(sys.modules, "agent", raising=False)
if with_load_pool:
_pool_data = pool_data or {}
class _FakeEntry:
"""Minimal PooledCredential stand-in with attribute access (matching the real class)."""
def __init__(self, d):
self.source = d.get("source", "manual")
self.label = d.get("label", "")
self.key_source = d.get("key_source", "")
self.id = d.get("id", "")
class _FakePool:
def __init__(self, entries_list):
self._entries = entries_list
def entries(self):
return self._entries
def _fake_load_pool(pid):
# Return ALL entries without filtering — mirrors the real load_pool()
# which does NOT suppress ambient gh-cli tokens on its own.
# Ambient-source filtering is the webui's responsibility.
raw = _pool_data.get(pid, [])
return _FakePool([_FakeEntry(e) for e in raw])
fake_cp = types.ModuleType("agent.credential_pool")
fake_cp.load_pool = _fake_load_pool
monkeypatch.setitem(sys.modules, "agent.credential_pool", fake_cp)
def _call_get_available_models(monkeypatch, tmp_path, auth_payload, *, with_load_pool: bool = False):
"""Call get_available_models() with auth.json pinned to a temp Hermes home."""
_install_fake_hermes_cli(
monkeypatch,
with_load_pool=with_load_pool,
pool_data=auth_payload.get("credential_pool", {}),
)
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
config.cfg["model"] = {}
try:
# Pin mtime to avoid reload_config() clobbering our in-memory cfg patch.
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
config.invalidate_models_cache()
try:
return config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
config.invalidate_models_cache()
def _group_by_provider(result):
return {g["provider"]: g["models"] for g in result.get("groups", [])}
def test_ollama_cloud_manual_credential_shows_group(monkeypatch, tmp_path):
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"ollama-cloud": [
{
"id": "abc123",
"label": "ollama-manual",
"source": "manual",
"auth_type": "api_key",
"base_url": "https://ollama.com/v1",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "Ollama Cloud" in groups, f"Expected Ollama Cloud in {list(groups)}"
model_ids = [m["id"] for m in groups["Ollama Cloud"]]
assert model_ids == ["@ollama-cloud:gpt-oss:20b", "@ollama-cloud:qwen3:30b-a3b"], model_ids
def test_copilot_gh_cli_only_credential_hidden(monkeypatch, tmp_path):
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "def456",
"label": "gh auth token",
"source": "gh_cli",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "GitHub Copilot" not in groups, (
"GitHub Copilot should be hidden when only ambient gh auth token is present; "
f"got {list(groups)}"
)
def test_copilot_mixed_credential_pool_remains_visible(monkeypatch, tmp_path):
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "def456",
"label": "gh auth token",
"source": "gh_cli",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
},
{
"id": "ghi789",
"label": "explicit-copilot",
"source": "manual",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
},
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
model_ids = [m["id"] for m in groups["GitHub Copilot"]]
assert "@copilot:gpt-5.4" in model_ids, model_ids
assert "@copilot:claude-opus-4.6" in model_ids, model_ids
def test_copilot_empty_field_entries_are_treated_as_explicit(monkeypatch, tmp_path):
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "jkl012",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
def test_copilot_oauth_credential_is_visible(monkeypatch, tmp_path):
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "mno345",
"label": "github-oauth",
"source": "oauth",
"auth_type": "oauth",
"base_url": "https://api.githubcopilot.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
# --- load_pool path (suppression-aware) ---
def test_load_pool_copilot_ambient_only_remains_hidden(monkeypatch, tmp_path):
"""load_pool path: copilot with only ambient gh-cli entries is suppressed."""
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "lp001",
"label": "gh auth token",
"source": "gh_cli",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
groups = _group_by_provider(result)
assert "GitHub Copilot" not in groups, (
"GitHub Copilot must be hidden when load_pool returns no usable entries; "
f"got {list(groups)}"
)
def test_load_pool_copilot_ambient_key_source_only_remains_hidden(monkeypatch, tmp_path):
"""load_pool path: key_source-only ambient markers must also be suppressed."""
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "lp001b",
"label": "copilot-token",
"source": "manual",
"key_source": "gh auth token",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
groups = _group_by_provider(result)
assert "GitHub Copilot" not in groups, (
"GitHub Copilot must stay hidden when load_pool entries only differ by key_source ambient markers; "
f"got {list(groups)}"
)
def test_load_pool_alias_provider_key_is_resolved(monkeypatch, tmp_path):
"""load_pool path: aliased pool keys should resolve to canonical provider ids."""
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"google": [
{
"id": "gp001",
"label": "explicit-gemini",
"source": "manual",
"auth_type": "api_key",
"base_url": "https://generativelanguage.googleapis.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
groups = _group_by_provider(result)
assert "Gemini" in groups, f"Expected Gemini in {list(groups)}"
assert "Google" not in groups, f"Aliased provider key should not render under raw alias name: {list(groups)}"
def test_load_pool_explicit_credential_shows_provider(monkeypatch, tmp_path):
"""load_pool path: provider with at least one explicit entry is visible."""
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "lp002",
"label": "gh auth token",
"source": "gh_cli",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
},
{
"id": "lp003",
"label": "explicit-pat",
"source": "manual",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
},
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
groups = _group_by_provider(result)
assert "GitHub Copilot" in groups, (
f"GitHub Copilot must appear when load_pool has at least one usable entry; got {list(groups)}"
)
# --- _apply_provider_prefix helper ---
def test_apply_provider_prefix_ollama_cloud_non_active():
"""Bare ollama-cloud model ids get @ollama-cloud: prefix when not active."""
from api.config import _apply_provider_prefix
raw = [{"id": "gpt-oss:20b", "label": "gpt-oss:20b"}, {"id": "qwen3:30b-a3b", "label": "qwen3:30b-a3b"}]
result = _apply_provider_prefix(raw, "ollama-cloud", "openai-codex")
ids = [m["id"] for m in result]
assert ids == ["@ollama-cloud:gpt-oss:20b", "@ollama-cloud:qwen3:30b-a3b"], ids
def test_apply_provider_prefix_copilot_non_active():
"""Bare copilot model ids get @copilot: prefix when not active."""
from api.config import _apply_provider_prefix
raw = [{"id": "gpt-5.4", "label": "GPT-5.4"}, {"id": "claude-opus-4.6", "label": "Claude Opus 4.6"}]
result = _apply_provider_prefix(raw, "copilot", "openai-codex")
ids = [m["id"] for m in result]
assert ids == ["@copilot:gpt-5.4", "@copilot:claude-opus-4.6"], ids
def test_apply_provider_prefix_no_double_prefix():
"""Already-prefixed or provider/model ids are not double-prefixed."""
from api.config import _apply_provider_prefix
raw = [
{"id": "@copilot:gpt-5.4", "label": "already prefixed"},
{"id": "openai/gpt-5.4", "label": "slash form"},
{"id": "bare-model", "label": "bare"},
]
result = _apply_provider_prefix(raw, "copilot", "openai-codex")
ids = [m["id"] for m in result]
assert ids == ["@copilot:gpt-5.4", "openai/gpt-5.4", "@copilot:bare-model"], ids
def test_apply_provider_prefix_active_provider_no_prefix():
"""No prefix is added when the provider is already the active one."""
from api.config import _apply_provider_prefix
raw = [{"id": "gpt-5.4", "label": "GPT-5.4"}]
result = _apply_provider_prefix(raw, "openai-codex", "openai-codex")
ids = [m["id"] for m in result]
assert ids == ["gpt-5.4"], ids
def test_copilot_mixed_pool_prefixed_models(monkeypatch, tmp_path):
"""Copilot with mixed pool and non-active provider has @copilot: prefixed model ids."""
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "lp010",
"label": "explicit-copilot",
"source": "manual",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "GitHub Copilot" in groups
model_ids = [m["id"] for m in groups["GitHub Copilot"]]
assert all(mid.startswith("@copilot:") for mid in model_ids), model_ids
def test_auth_store_active_provider_alias_is_resolved(monkeypatch, tmp_path):
"""active_provider read from auth.json must be alias-normalized.
Regression: previously the alias table was applied only to config.yaml's
active_provider, so an aliased name in auth.json (e.g. 'google') would
not match the canonical pid ('gemini') and the prefixing logic would
add an unwanted '@gemini:' prefix to the active provider's models.
"""
auth_payload = {
"version": 1,
"providers": {},
# Aliased name: 'google' → 'gemini' per _PROVIDER_ALIASES.
"active_provider": "google",
"credential_pool": {},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
# Gemini should appear under its canonical display name and its model
# ids should NOT be prefixed (it's the active provider).
assert "Gemini" in groups, f"Expected Gemini in {list(groups)}"
model_ids = [m["id"] for m in groups["Gemini"]]
assert model_ids, "Gemini group should have models"
assert not any(mid.startswith("@") for mid in model_ids), (
f"Active provider models must not be prefixed; got {model_ids}"
)
def test_ollama_cloud_empty_catalog_skips_group(monkeypatch, tmp_path):
"""When hermes_cli returns no models for ollama-cloud, the group is omitted.
Matches the named-custom and unknown-provider branches: we don't invent a
catalog we can't enumerate. The logger.warning in the except branch keeps
diagnostics available for operators.
"""
_install_fake_hermes_cli(monkeypatch)
# Override the stub to return empty for ollama-cloud.
import sys as _sys
_sys.modules["hermes_cli.models"].provider_model_ids = lambda pid: []
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"ollama-cloud": [
{
"id": "oc-empty",
"label": "ollama-manual",
"source": "manual",
"auth_type": "api_key",
}
]
},
}
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
config.cfg["model"] = {}
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
try:
result = config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
groups = _group_by_provider(result)
assert "Ollama Cloud" not in groups, (
f"Ollama Cloud group should be skipped when catalog is empty; got {list(groups)}"
)
# --- _format_ollama_label helper ---
def test_format_ollama_label_simple():
from api.config import _format_ollama_label
assert _format_ollama_label("kimi-k2.5") == "Kimi K2.5"
def test_format_ollama_label_with_variant():
from api.config import _format_ollama_label
assert _format_ollama_label("qwen3-vl:235b-instruct") == "Qwen3 VL (235B Instruct)"
def test_format_ollama_label_short_acronym():
from api.config import _format_ollama_label
assert _format_ollama_label("glm-5.1") == "GLM 5.1"
def test_format_ollama_label_gpt_oss_with_size():
from api.config import _format_ollama_label
assert _format_ollama_label("gpt-oss:20b") == "GPT OSS (20B)"
def test_format_ollama_label_empty_string():
from api.config import _format_ollama_label
assert _format_ollama_label("") == ""
def test_format_ollama_label_no_variant():
from api.config import _format_ollama_label
assert _format_ollama_label("nemotron-3-super") == "Nemotron 3 Super"
# --- Fallback-path (ImportError branch) alias resolution ---
def test_fallback_path_resolves_alias_when_load_pool_unavailable(monkeypatch, tmp_path):
"""When agent.credential_pool can't be imported, the manual-inspection
branch must still canonicalize pool keys so aliased names (e.g. 'google')
end up under their canonical provider id ('gemini')."""
_install_fake_hermes_cli(monkeypatch)
# Ensure agent.credential_pool is not importable so the fallback branch runs.
monkeypatch.setitem(sys.modules, "agent.credential_pool", None)
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"google": [
{
"id": "gp-fallback",
"label": "explicit-gemini",
"source": "manual",
"auth_type": "api_key",
}
]
},
}
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
config.cfg["model"] = {}
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
try:
result = config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
groups = _group_by_provider(result)
assert "Gemini" in groups, (
f"Fallback path must resolve 'google' -> 'gemini'; got {list(groups)}"
)
assert "Google" not in groups, (
f"Raw alias name must not leak when fallback path runs; got {list(groups)}"
)

View File

@@ -0,0 +1,110 @@
"""Tests for #835 — refresh button in Tasks / Scheduled Jobs panel."""
import os
import re
_SRC = os.path.join(os.path.dirname(__file__), "..")
def _read(name):
return open(os.path.join(_SRC, name), encoding="utf-8").read()
class TestCronRefreshButtonHtml:
"""index.html must expose a refresh button in the Tasks panel header."""
def test_refresh_button_present(self):
html = _read("static/index.html")
assert 'id="cronRefreshBtn"' in html, (
"Tasks panel must have a #cronRefreshBtn element"
)
def test_refresh_button_has_accessibility_labels(self):
"""Icon-only buttons need aria-label + title so screen readers and
hover tooltips work."""
html = _read("static/index.html")
m = re.search(r'<button[^>]*id="cronRefreshBtn"[^>]*>', html)
assert m, "cronRefreshBtn tag not found"
tag = m.group(0)
assert 'aria-label=' in tag, (
"#cronRefreshBtn is icon-only and must have aria-label"
)
assert 'title=' in tag, (
"#cronRefreshBtn should have a title tooltip"
)
def test_refresh_button_calls_load_crons_with_animate(self):
html = _read("static/index.html")
m = re.search(r'<button[^>]*id="cronRefreshBtn"[^>]*>', html)
assert m
tag = m.group(0)
assert 'loadCrons(true)' in tag, (
"#cronRefreshBtn must call loadCrons(true) to enable the dim-while-fetching animation"
)
def test_refresh_button_sits_next_to_new_job_button(self):
"""Refresh button should appear in the same header row as the New Job
button so the header layout stays tight."""
html = _read("static/index.html")
ref_pos = html.find('id="cronRefreshBtn"')
newjob_pos = html.find('openCronCreate()')
assert ref_pos != -1 and newjob_pos != -1
# Must be close enough to be in the same header row (single SVG-inline
# button can be around 500 chars by itself due to inline styles/attrs).
assert abs(ref_pos - newjob_pos) < 1000, (
"Refresh button and New Job button should be in the same header row"
)
class TestLoadCronsAnimateFlag:
"""panels.js loadCrons() must accept an optional animate flag that dims
the refresh button while fetching."""
def test_load_crons_accepts_animate_param(self):
js = _read("static/panels.js")
assert re.search(r'async function loadCrons\s*\(\s*animate\s*\)', js), (
"loadCrons must accept an `animate` parameter"
)
def test_load_crons_restores_button_in_finally(self):
"""The opacity/disabled restore MUST be in a finally block so a
throwing fetch doesn't leave the button stuck at 0.5 / disabled."""
js = _read("static/panels.js")
m = re.search(r'async function loadCrons\(.*?\n\}', js, re.DOTALL)
assert m, "loadCrons body not found"
fn = m.group(0)
assert 'finally' in fn, (
"loadCrons must restore the refresh button's opacity/disabled state "
"in a finally block so errors during fetch don't leave the button stuck"
)
# The restore block sets opacity='' (not '1') so CSS cascade wins
assert "opacity = ''" in fn or "opacity=''" in fn, (
"restore must use opacity='' to clear the inline override"
)
class TestCronCreatedEventListener:
"""A global `hermes:cron_created` listener must be registered so
future chat paths can trigger the cron list refresh."""
def test_listener_registered_at_module_scope(self):
js = _read("static/panels.js")
assert re.search(
r"addEventListener\(\s*['\"]hermes:cron_created['\"]",
js,
), (
"panels.js must register a window-level 'hermes:cron_created' event listener"
)
def test_listener_triggers_load_crons(self):
js = _read("static/panels.js")
m = re.search(
r"addEventListener\(\s*['\"]hermes:cron_created['\"].*?\}\s*\)",
js,
re.DOTALL,
)
assert m, "hermes:cron_created listener body not found"
body = m.group(0)
assert 'loadCrons' in body, (
"hermes:cron_created listener must call loadCrons() to refresh the list"
)

View File

@@ -0,0 +1,148 @@
"""Tests for cron session title fallback in get_cli_sessions().
When a CLI session originates from cron and has no title in state.db, the
WebUI sidebar should display the human-friendly job name from cron/jobs.json
instead of a generic "Cron Session" label.
Session ID format produced by hermes-agent: cron_<job_id>_<YYYYMMDD>_<HHMMSS>
"""
import json
import sqlite3
import pytest
import api.models as models
def _make_state_db(path, sessions):
"""Create a state.db with the schema get_cli_sessions() expects.
`sessions` is a list of (id, title, source) tuples.
"""
conn = sqlite3.connect(str(path))
conn.execute("""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT,
message_count INTEGER,
started_at REAL,
source TEXT
)
""")
conn.execute("""
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
timestamp REAL
)
""")
for sid, title, source in sessions:
conn.execute(
"INSERT INTO sessions (id, title, model, message_count, started_at, source) "
"VALUES (?, ?, ?, ?, ?, ?)",
(sid, title, "gpt-x", 1, 1700000000.0, source),
)
conn.execute(
"INSERT INTO messages (session_id, timestamp) VALUES (?, ?)",
(sid, 1700000001.0),
)
conn.commit()
conn.close()
def _write_jobs_json(hermes_home, jobs):
"""Write cron/jobs.json with the given jobs list."""
cron_dir = hermes_home / "cron"
cron_dir.mkdir(parents=True, exist_ok=True)
(cron_dir / "jobs.json").write_text(
json.dumps({"jobs": jobs}), encoding="utf-8"
)
@pytest.fixture
def fake_hermes_home(tmp_path, monkeypatch):
"""Point get_cli_sessions() at a temporary HERMES_HOME and disable
profile lookups so the test runs hermetically."""
home = tmp_path / "hermes"
home.mkdir()
# Both profile helpers are imported lazily inside get_cli_sessions(),
# so patching the api.profiles module reaches them.
import api.profiles as profiles
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: home)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: None)
return home
def test_cron_session_uses_job_name_when_title_missing(fake_hermes_home):
"""A cron session with no title should display the friendly job name."""
_write_jobs_json(fake_hermes_home, [
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
])
_make_state_db(fake_hermes_home / "state.db", [
("cron_cd65df6fc1a8_20260417_191049", None, "cron"),
])
sessions = models.get_cli_sessions()
assert len(sessions) == 1
assert sessions[0]["title"] == "wiki-auto-ingest"
def test_cron_session_falls_back_when_jobs_json_missing(fake_hermes_home):
"""No jobs.json should not crash; title falls back to 'Cron Session'."""
_make_state_db(fake_hermes_home / "state.db", [
("cron_abc123_20260417_191049", None, "cron"),
])
sessions = models.get_cli_sessions()
assert sessions[0]["title"] == "Cron Session"
def test_cron_session_falls_back_when_job_id_not_in_jobs_json(fake_hermes_home):
"""Stale session whose job has been deleted falls back gracefully."""
_write_jobs_json(fake_hermes_home, [
{"id": "different_job", "name": "Some Other Job"},
])
_make_state_db(fake_hermes_home / "state.db", [
("cron_orphan_20260417_191049", None, "cron"),
])
sessions = models.get_cli_sessions()
assert sessions[0]["title"] == "Cron Session"
def test_explicit_title_is_preserved(fake_hermes_home):
"""If state.db already has a title, the cron job lookup should not
override it."""
_write_jobs_json(fake_hermes_home, [
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
])
_make_state_db(fake_hermes_home / "state.db", [
("cron_cd65df6fc1a8_20260417_191049", "User-edited title", "cron"),
])
sessions = models.get_cli_sessions()
assert sessions[0]["title"] == "User-edited title"
def test_non_cron_sessions_unaffected(fake_hermes_home):
"""The cron-name lookup must not run for cli-source sessions, so the
generic 'Cli Session' fallback still applies when title is empty."""
_write_jobs_json(fake_hermes_home, [
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
])
# A 'cli' session whose ID coincidentally starts with 'cron_' must not
# pick up the job name — the source check guards against this.
_make_state_db(fake_hermes_home / "state.db", [
("cron_cd65df6fc1a8_xx", None, "cli"),
])
sessions = models.get_cli_sessions()
assert sessions[0]["title"] == "Cli Session"

View File

@@ -0,0 +1,165 @@
"""
Tests for named custom provider display in the model dropdown (issue #557).
When a custom_providers entry carries a `name` field (e.g. "Agent37"), the
web UI model picker should show that name as the group header rather than the
generic "Custom" label.
"""
import pytest
import api.config as config
@pytest.fixture(autouse=True)
def _isolate_models_cache():
"""Invalidate the models TTL cache before and after every test in this file."""
try:
config.invalidate_models_cache()
except Exception:
pass
yield
try:
config.invalidate_models_cache()
except Exception:
pass
def _models_with_cfg(model_cfg=None, custom_providers=None, active_provider=None):
"""Temporarily patch config.cfg, call get_available_models(), restore.
Also pins _cfg_mtime to the current config.yaml mtime before calling
get_available_models(). Without this, if a prior test wrote config.yaml
(changing its mtime), the mtime-guard inside get_available_models() fires
reload_config() which overwrites config.cfg with the real on-disk values,
silently discarding the patch and causing ordering-dependent failures.
This matches the pattern used in test_model_resolver.py.
"""
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
if model_cfg:
config.cfg["model"] = model_cfg
if custom_providers is not None:
config.cfg["custom_providers"] = custom_providers
# Pin mtime so get_available_models() skips its reload_config() guard.
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0 # no config.yaml present; reload guard is a no-op
try:
return config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
# ── Named provider shows its name in the dropdown ─────────────────────────────
class TestNamedCustomProviderGroup:
def test_named_provider_uses_name_as_group_header(self):
"""A custom_provider entry with name='Agent37' should produce
a group whose 'provider' key is 'Agent37', not 'Custom'."""
result = _models_with_cfg(
model_cfg={"provider": "custom", "base_url": "https://agent37.example.com/v1"},
custom_providers=[
{"name": "Agent37", "model": "default", "base_url": "https://agent37.example.com/v1"}
],
)
group_names = [g["provider"] for g in result.get("groups", [])]
assert "Agent37" in group_names, (
f"Expected 'Agent37' in group names, got {group_names}"
)
def test_named_provider_does_not_produce_generic_custom(self):
"""When all custom_provider entries have names, no group called 'Custom'
should appear alongside them."""
result = _models_with_cfg(
model_cfg={"provider": "custom", "base_url": "https://agent37.example.com/v1"},
custom_providers=[
{"name": "Agent37", "model": "default", "base_url": "https://agent37.example.com/v1"}
],
)
group_names = [g["provider"] for g in result.get("groups", [])]
assert "Custom" not in group_names, (
f"Expected no generic 'Custom' group when all entries are named, got {group_names}"
)
def test_named_provider_model_appears_in_its_group(self):
"""The model ID from the named entry should be inside the named group."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{"name": "Agent37", "model": "my-llm", "base_url": "https://agent37.example.com/v1"}
],
)
agent37_group = next(
(g for g in result.get("groups", []) if g["provider"] == "Agent37"), None
)
assert agent37_group is not None, "Expected an 'Agent37' group"
model_ids = [m["id"] for m in agent37_group.get("models", [])]
assert "my-llm" in model_ids, (
f"Expected 'my-llm' in Agent37 group models, got {model_ids}"
)
def test_multiple_named_providers_each_get_their_own_group(self):
"""Two named custom providers should produce two distinct groups."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{"name": "Agent37", "model": "fast-model"},
{"name": "PrivateProxy", "model": "private-llm"},
],
)
group_names = [g["provider"] for g in result.get("groups", [])]
assert "Agent37" in group_names, f"Expected 'Agent37' group, got {group_names}"
assert "PrivateProxy" in group_names, f"Expected 'PrivateProxy' group, got {group_names}"
assert "Custom" not in group_names, f"No generic 'Custom' group expected, got {group_names}"
def test_multiple_models_in_same_named_provider(self):
"""Multiple entries with the same name should be collapsed into one group."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{"name": "Agent37", "model": "model-a"},
{"name": "Agent37", "model": "model-b"},
],
)
agent37_groups = [g for g in result.get("groups", []) if g["provider"] == "Agent37"]
assert len(agent37_groups) == 1, (
f"Expected exactly one 'Agent37' group, got {len(agent37_groups)}"
)
model_ids = [m["id"] for m in agent37_groups[0].get("models", [])]
assert "model-a" in model_ids
assert "model-b" in model_ids
# ── Unnamed entry still falls back to 'Custom' ─────────────────────────────────
class TestUnnamedCustomProviderFallback:
def test_unnamed_entry_still_produces_custom_group(self):
"""A custom_provider entry without a name should still show as 'Custom'."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{"model": "unnamed-model"}
],
)
group_names = [g["provider"] for g in result.get("groups", [])]
assert "Custom" in group_names, (
f"Expected generic 'Custom' group for unnamed entry, got {group_names}"
)
def test_mixed_named_and_unnamed_entries(self):
"""Named and unnamed entries should appear in their respective groups."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{"name": "Agent37", "model": "named-model"},
{"model": "unnamed-model"},
],
)
group_names = [g["provider"] for g in result.get("groups", [])]
assert "Agent37" in group_names, f"Expected 'Agent37' group, got {group_names}"
assert "Custom" in group_names, f"Expected 'Custom' group for unnamed entry, got {group_names}"

View File

@@ -0,0 +1,148 @@
import json
from pathlib import Path
import api.config as config
def test_resolve_default_workspace_falls_back_to_existing_home_work(monkeypatch, tmp_path):
preferred = tmp_path / "work"
preferred.mkdir()
state_dir = tmp_path / "state"
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
resolved = config.resolve_default_workspace("/definitely/not/usable")
assert resolved == preferred.resolve()
def test_save_settings_rewrites_bad_default_workspace_to_fallback(monkeypatch, tmp_path):
preferred = tmp_path / "work"
preferred.mkdir()
state_dir = tmp_path / "state"
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", preferred)
saved = config.save_settings({"default_workspace": "/definitely/not/usable"})
on_disk = json.loads(settings_file.read_text(encoding="utf-8"))
assert saved["default_workspace"] == str(preferred.resolve())
assert on_disk["default_workspace"] == str(preferred.resolve())
def test_resolve_default_workspace_creates_home_workspace_when_missing(monkeypatch, tmp_path):
"""When no preferred dir exists, resolve falls back to creating ~/workspace."""
state_dir = tmp_path / "state"
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
# Neither ~/work nor ~/workspace exists yet
resolved = config.resolve_default_workspace(None)
assert resolved == (tmp_path / "workspace").resolve()
assert resolved.is_dir()
def test_resolve_default_workspace_raises_when_all_candidates_fail(monkeypatch, tmp_path):
"""RuntimeError is raised when every candidate is unwritable."""
import stat, pytest
# Make tmp_path read-only so mkdir inside it fails
tmp_path.chmod(stat.S_IRUSR | stat.S_IXUSR)
state_dir = tmp_path / "state"
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
try:
with pytest.raises(RuntimeError, match="Could not create or access"):
config.resolve_default_workspace(None)
finally:
tmp_path.chmod(stat.S_IRWXU) # restore for cleanup
def test_workspace_candidates_deduplicates_home_workspace(monkeypatch, tmp_path):
"""~/workspace must appear at most once in the candidates list even if it exists."""
ws = tmp_path / "workspace"
ws.mkdir()
state_dir = tmp_path / "state"
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
candidates = config._workspace_candidates(None)
paths = [str(p) for p in candidates]
assert paths.count(str(ws.resolve())) <= 1, "~/workspace must not appear twice"
def test_env_var_workspace_takes_priority_over_passed_raw(monkeypatch, tmp_path):
"""HERMES_WEBUI_DEFAULT_WORKSPACE env var overrides a None raw arg but not a valid one."""
env_ws = tmp_path / "env_workspace"
env_ws.mkdir()
state_dir = tmp_path / "state"
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.setenv("HERMES_WEBUI_DEFAULT_WORKSPACE", str(env_ws))
# When raw is None, env var should be used
resolved = config.resolve_default_workspace(None)
assert resolved == env_ws.resolve()
def test_ensure_workspace_dir_returns_false_for_unwritable_path(monkeypatch, tmp_path):
"""_ensure_workspace_dir returns False for a path that can't be created."""
import stat
# Make parent read-only so mkdir fails
parent = tmp_path / "ro_parent"
parent.mkdir()
parent.chmod(stat.S_IRUSR | stat.S_IXUSR)
try:
result = config._ensure_workspace_dir(parent / "child")
assert result is False
finally:
parent.chmod(stat.S_IRWXU)
def test_env_var_wins_over_settings_json_on_startup(monkeypatch, tmp_path):
"""HERMES_WEBUI_DEFAULT_WORKSPACE must not be overridden by settings.json at startup.
Regression for GitHub issue #609: Docker deployments set the env var to a
volume mount, but settings.json from a previous container run used to
silently win, reverting the files panel to the old path.
"""
import json as _json
import os as _os
env_ws = tmp_path / "env_workspace"
env_ws.mkdir()
settings_ws = tmp_path / "settings_workspace"
settings_ws.mkdir()
state_dir = tmp_path / "state"
state_dir.mkdir()
settings_file = state_dir / "settings.json"
settings_file.write_text(
_json.dumps({"default_workspace": str(settings_ws)}), encoding="utf-8"
)
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
# Simulate DEFAULT_WORKSPACE already set correctly from env var at import time
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", env_ws.resolve())
monkeypatch.setenv("HERMES_WEBUI_DEFAULT_WORKSPACE", str(env_ws))
# Execute the patched startup block logic inline — env var present → skip override
current_ws = config.DEFAULT_WORKSPACE
startup_settings = config.load_settings()
if not _os.getenv("HERMES_WEBUI_DEFAULT_WORKSPACE"):
# This branch must be skipped because env var is set
current_ws = config.resolve_default_workspace(
startup_settings.get("default_workspace")
)
# env var was set → the if block was skipped → env path wins over settings.json
assert current_ws == env_ws.resolve(), (
f"Expected {env_ws.resolve()}, got {current_ws}. "
"settings.json must not override HERMES_WEBUI_DEFAULT_WORKSPACE."
)

View File

@@ -0,0 +1,228 @@
"""Tests for font size setting (#833) — 3-toggle Small/Default/Large in Appearance."""
import os
import re
_SRC = os.path.join(os.path.dirname(__file__), "..")
def _read(name):
return open(os.path.join(_SRC, name), encoding="utf-8").read()
class TestFontSizeCssModifiers:
"""CSS must define font-size overrides for small and large via data attribute."""
def test_small_font_size_rule_exists(self):
css = _read("static/style.css")
assert 'data-font-size="small"' in css, (
"style.css must have :root[data-font-size=\"small\"] font-size rule"
)
def test_large_font_size_rule_exists(self):
css = _read("static/style.css")
assert 'data-font-size="large"' in css, (
"style.css must have :root[data-font-size=\"large\"] font-size rule"
)
def test_small_is_smaller_than_default(self):
css = _read("static/style.css")
# Match both compact {font-size:12px} and spaced { font-size: 12px; } formats
m_small = re.search(r':root\[data-font-size="small"\][^{]*\{[^}]*font-size:\s*(\d+)px', css)
m_large = re.search(r':root\[data-font-size="large"\][^{]*\{[^}]*font-size:\s*(\d+)px', css)
assert m_small and m_large, "Both small and large font-size rules must set px values"
assert int(m_small.group(1)) < 14, "Small font size must be < 14px (default)"
assert int(m_large.group(1)) > 14, "Large font size must be > 14px (default)"
class TestFontSizeBootScript:
"""The boot script must apply font size from localStorage before page renders."""
def test_boot_script_reads_hermes_font_size(self):
html = _read("static/index.html")
assert "hermes-font-size" in html, (
"index.html boot script must read 'hermes-font-size' from localStorage"
)
assert "data-font-size" in html, (
"boot script must set document.documentElement.dataset.fontSize"
)
def test_font_size_picker_html_present(self):
html = _read("static/index.html")
assert "fontSizePickerGrid" in html, (
"Appearance pane must contain a fontSizePickerGrid element"
)
assert "settingsFontSize" in html, (
"Appearance pane must contain a hidden #settingsFontSize input"
)
assert "font-size-pick-btn" in html, (
"Font size picker buttons must have font-size-pick-btn class"
)
def test_three_font_size_values_present(self):
html = _read("static/index.html")
assert 'data-font-size-val="small"' in html, "Small button must exist"
assert 'data-font-size-val="default"' in html, "Default button must exist"
assert 'data-font-size-val="large"' in html, "Large button must exist"
def test_font_size_picker_not_duplicated(self):
"""Regression guard: the font size picker grid must appear exactly once
in index.html. Earlier versions of this PR accidentally injected the
block into both settingsPaneAppearance (correct) and
settingsPanePreferences (copy-paste duplicate), creating duplicate IDs
that break _syncFontSizePicker visual sync on one of the grids."""
html = _read("static/index.html")
assert html.count('id="fontSizePickerGrid"') == 1, (
"fontSizePickerGrid must appear exactly once — duplicate IDs "
"violate HTML spec and break querySelectorAll-based sync."
)
assert html.count('id="settingsFontSize"') == 1, (
"settingsFontSize hidden input must appear exactly once"
)
def test_font_size_picker_lives_in_appearance_pane(self):
"""The font size picker must be under settingsPaneAppearance,
not Preferences/System/Conversation."""
html = _read("static/index.html")
appearance_start = html.find('id="settingsPaneAppearance"')
next_pane_markers = [
'id="settingsPanePreferences"',
'id="settingsPaneSystem"',
'id="settingsPaneConversation"',
]
next_pane_starts = [
html.find(m, appearance_start + 1) for m in next_pane_markers
]
after_appearance = min(
[p for p in next_pane_starts if p != -1] or [len(html)]
)
picker_pos = html.find('id="fontSizePickerGrid"')
assert appearance_start != -1, "settingsPaneAppearance not found"
assert picker_pos != -1, "fontSizePickerGrid not found"
assert appearance_start < picker_pos < after_appearance, (
"Font size picker must live inside settingsPaneAppearance "
"(same section as Theme and Skin)"
)
class TestFontSizeJsFunctions:
"""JS must expose _pickFontSize, _applyFontSize, and _syncFontSizePicker."""
def test_pick_font_size_function_exists(self):
boot = _read("static/boot.js")
assert "function _pickFontSize(" in boot, (
"boot.js must define _pickFontSize()"
)
def test_apply_font_size_function_exists(self):
boot = _read("static/boot.js")
assert "function _applyFontSize(" in boot, (
"boot.js must define _applyFontSize()"
)
def test_sync_font_size_picker_function_exists(self):
boot = _read("static/boot.js")
assert "function _syncFontSizePicker(" in boot, (
"boot.js must define _syncFontSizePicker()"
)
def test_pick_font_size_persists_to_localstorage(self):
boot = _read("static/boot.js")
idx = boot.find("function _pickFontSize(")
block = boot[idx:idx+400]
assert "localStorage.setItem('hermes-font-size'" in block, (
"_pickFontSize must persist choice to localStorage"
)
def test_apply_font_size_sets_data_attribute(self):
boot = _read("static/boot.js")
idx = boot.find("function _applyFontSize(")
block = boot[idx:idx+300]
assert "dataset.fontSize" in block, (
"_applyFontSize must set document.documentElement.dataset.fontSize"
)
class TestFontSizeI18nCoverage:
"""All locales must include the font size i18n keys."""
def _get_locale_keys(self, src, locale_marker_after, stop_marker):
"""Extract keys from a locale block."""
start = src.find(locale_marker_after)
if start < 0:
return set()
end = src.find(stop_marker, start)
block = src[start:end if end > 0 else start + 20000]
return set(re.findall(r"(\w[\w_]+):", block))
REQUIRED_KEYS = {"settings_label_font_size", "font_size_small", "font_size_default", "font_size_large"}
def test_all_locales_have_font_size_keys(self):
src = _read("static/i18n.js")
count = src.count("settings_label_font_size")
# 6 locales: en, ru, es, de, zh, zh-Hant
assert count >= 6, (
f"settings_label_font_size must appear in all 6 locales, found {count}"
)
def test_font_size_small_key_in_all_locales(self):
src = _read("static/i18n.js")
count = src.count("font_size_small")
assert count >= 6, f"font_size_small must appear in all 6 locales, found {count}"
def test_font_size_large_key_in_all_locales(self):
src = _read("static/i18n.js")
count = src.count("font_size_large")
assert count >= 6, f"font_size_large must appear in all 6 locales, found {count}"
class TestFontSizeCssTargetedOverrides:
"""CSS must override px-unit text in key UI elements, not just :root font-size.
The original PR only set :root font-size, but the stylesheet uses hardcoded px
values throughout — changing :root has no effect on those. This test class locks
in the targeted overrides for the most visible UI surfaces.
"""
def test_msg_body_overridden_for_small(self):
css = _read("static/style.css")
assert ':root[data-font-size="small"] .msg-body' in css, \
"Chat message text must be explicitly scaled for small"
def test_msg_body_overridden_for_large(self):
css = _read("static/style.css")
assert ':root[data-font-size="large"] .msg-body' in css, \
"Chat message text must be explicitly scaled for large"
def test_session_item_overridden_for_small(self):
css = _read("static/style.css")
assert ':root[data-font-size="small"] .session-item' in css, \
"Sidebar session list text must be explicitly scaled for small"
def test_session_item_overridden_for_large(self):
css = _read("static/style.css")
assert ':root[data-font-size="large"] .session-item' in css, \
"Sidebar session list text must be explicitly scaled for large"
def test_composer_overridden_for_small(self):
css = _read("static/style.css")
assert ':root[data-font-size="small"] #msg' in css, \
"Composer textarea must be explicitly scaled for small"
def test_composer_overridden_for_large(self):
css = _read("static/style.css")
assert ':root[data-font-size="large"] #msg' in css, \
"Composer textarea must be explicitly scaled for large"
# Large composer must not equal the default 16px — that's a no-op
import re
m = re.search(r':root\[data-font-size="large"\] #msg \{ font-size: (\d+)px', css)
assert m and int(m.group(1)) != 16, \
"Large composer font-size must differ from default (16px) to have visible effect"
def test_file_item_overridden_for_small(self):
css = _read("static/style.css")
assert ':root[data-font-size="small"] .file-item' in css, \
"Workspace file tree text must be explicitly scaled for small"
def test_file_item_overridden_for_large(self):
css = _read("static/style.css")
assert ':root[data-font-size="large"] .file-item' in css, \
"Workspace file tree text must be explicitly scaled for large"

View File

@@ -18,7 +18,7 @@ import urllib.error
import urllib.request
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):
@@ -49,11 +49,9 @@ def _get_test_state_dir():
set (e.g. when running this file standalone), fall back to the conftest
formula: HERMES_HOME/webui-mvp-test.
"""
explicit = os.getenv('HERMES_WEBUI_TEST_STATE_DIR')
if explicit:
return pathlib.Path(explicit)
hermes_home = pathlib.Path(os.getenv('HERMES_HOME', str(pathlib.Path.home() / '.hermes')))
return hermes_home / 'webui-mvp-test' # matches conftest.py TEST_STATE_DIR formula
# Use _pytest_port which applies the same auto-derivation as conftest.py
from tests._pytest_port import TEST_STATE_DIR as _ptsd
return _ptsd
def _get_state_db_path():
@@ -158,6 +156,79 @@ def test_gateway_sessions_appear_when_enabled():
post('/api/settings', {'show_cli_sessions': False})
def test_gateway_sessions_without_messages_are_hidden_from_sidebar():
"""Regression: empty agent session rows must not appear as broken sidebar entries."""
conn = _ensure_state_db()
empty_sid = 'gw_empty_no_messages_001'
try:
conn.execute(
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
"VALUES (?, ?, ?, ?, ?, ?)",
(empty_sid, 'cron', 'Cron Session', 'openai/gpt-5', time.time(), 0),
)
conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
conn.commit()
post('/api/settings', {'show_cli_sessions': True})
data, status = get('/api/sessions')
assert status == 200
sessions = data.get('sessions', [])
assert empty_sid not in {s.get('session_id') for s in sessions}, (
"Agent sessions with no readable message rows should be filtered before "
"they reach the sidebar; otherwise clicking them fails during import."
)
finally:
try:
_remove_test_sessions(conn, empty_sid)
conn.close()
except Exception:
pass
post('/api/settings', {'show_cli_sessions': False})
def test_gateway_watcher_hides_sessions_without_messages(monkeypatch):
"""Regression: SSE watcher must use the same importable-agent filter."""
conn = _ensure_state_db()
empty_sid = 'gw_empty_watcher_001'
live_sid = 'gw_live_watcher_001'
try:
conn.execute(
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
"VALUES (?, ?, ?, ?, ?, ?)",
(empty_sid, 'cron', 'Empty Cron Session', 'openai/gpt-5', time.time(), 0),
)
conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
_insert_gateway_session(
conn,
session_id=live_sid,
source='cron',
title='Live Cron Session',
message_count=0,
)
import api.gateway_watcher as gateway_watcher
monkeypatch.setattr(gateway_watcher, '_get_state_db_path', _get_state_db_path)
sessions = gateway_watcher._get_agent_sessions_from_db()
ids = {s.get('session_id') for s in sessions}
live = next((s for s in sessions if s.get('session_id') == live_sid), None)
assert empty_sid not in ids
assert live is not None
assert live.get('message_count') == 2, (
"Watcher should fall back to actual message rows when stored "
"message_count is zero, matching the sidebar route."
)
finally:
try:
_remove_test_sessions(conn, empty_sid, live_sid)
conn.close()
except Exception:
pass
def test_gateway_sessions_excluded_when_disabled():
"""Gateway sessions are NOT returned when show_cli_sessions is off."""
conn = _ensure_state_db()
@@ -275,6 +346,64 @@ def test_gateway_session_messages_readable():
post('/api/settings', {'show_cli_sessions': False})
def test_importing_older_gateway_session_preserves_original_timestamps_and_order():
"""Importing an older gateway session should not bump it above newer WebUI sessions."""
conn = _ensure_state_db()
older_started_at = time.time() - 1800
imported_sid = 'gw_import_old_001'
newer_webui_sid = None
try:
newer_webui, status = post('/api/session/new', {'model': 'openai/gpt-5'})
assert status == 200, newer_webui
newer_webui_sid = newer_webui['session']['session_id']
rename, rename_status = post(
'/api/session/rename',
{'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
)
assert rename_status == 200, rename
_insert_gateway_session(
conn,
session_id=imported_sid,
source='discord',
title='Older imported gateway session',
started_at=older_started_at,
)
post('/api/settings', {'show_cli_sessions': True})
imported, imported_status = post('/api/session/import_cli', {'session_id': imported_sid})
assert imported_status == 200, imported
imported_session = imported['session']
assert abs(imported_session['created_at'] - older_started_at) < 2, imported_session
assert abs(imported_session['updated_at'] - older_started_at) < 5, imported_session
sessions_payload, sessions_status = get('/api/sessions')
assert sessions_status == 200, sessions_payload
ordered_ids = [item['session_id'] for item in sessions_payload.get('sessions', [])]
assert newer_webui_sid in ordered_ids, ordered_ids
assert imported_sid in ordered_ids, ordered_ids
assert ordered_ids.index(newer_webui_sid) < ordered_ids.index(imported_sid), ordered_ids
finally:
try:
_remove_test_sessions(conn, imported_sid)
conn.close()
except Exception:
pass
if imported_sid:
try:
post('/api/session/delete', {'session_id': imported_sid})
except Exception:
pass
if newer_webui_sid:
try:
post('/api/session/delete', {'session_id': newer_webui_sid})
except Exception:
pass
post('/api/settings', {'show_cli_sessions': False})
def test_gateway_sse_stream_endpoint_exists():
"""GET /api/sessions/gateway/stream returns a response (200 or 200-range)."""
# The SSE endpoint requires show_cli_sessions to be enabled
@@ -296,6 +425,23 @@ def test_gateway_sse_stream_endpoint_exists():
post('/api/settings', {'show_cli_sessions': False})
def test_gateway_sse_stream_probe_reports_status():
"""Probe mode returns JSON watcher status instead of holding open an SSE stream."""
post('/api/settings', {'show_cli_sessions': True})
try:
req = urllib.request.Request(BASE + '/api/sessions/gateway/stream?probe=1')
with urllib.request.urlopen(req, timeout=5) as r:
assert r.status == 200, f"Expected 200, got {r.status}"
ctype = r.headers.get('Content-Type', '')
assert 'application/json' in ctype, f"Expected application/json, got {ctype}"
data = json.loads(r.read().decode('utf-8'))
assert data['enabled'] is True
assert 'watcher_running' in data
assert data['fallback_poll_ms'] == 30000
finally:
post('/api/settings', {'show_cli_sessions': False})
def test_gateway_webui_sessions_not_duplicated():
"""If a session_id exists both in WebUI store and state.db, it's not duplicated."""
# Create a WebUI session with a known ID
@@ -362,3 +508,124 @@ def test_cli_sessions_still_work():
except Exception:
pass
post('/api/settings', {'show_cli_sessions': False})
# ── Unit tests for _gateway_sse_probe_payload ────────────────────────────────
# These replace the deleted repo-root test_gateway_sse_probe_unit.py and account
# for the watcher_alive check (thread existence + is_alive()).
import sys
import threading
sys.path.insert(0, str(REPO_ROOT))
from api.routes import _gateway_sse_probe_payload
def test_probe_payload_when_disabled():
"""Probe returns 404 when show_cli_sessions is False."""
body, status = _gateway_sse_probe_payload({'show_cli_sessions': False}, watcher=None)
assert status == 404
assert body['ok'] is False
assert body['enabled'] is False
assert body['watcher_running'] is False
assert body['error'] == 'agent sessions not enabled'
assert body['fallback_poll_ms'] == 30000
def test_probe_payload_when_watcher_missing():
"""Probe returns 503 when enabled but no watcher instance."""
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=None)
assert status == 503
assert body['ok'] is False
assert body['enabled'] is True
assert body['watcher_running'] is False
assert body['error'] == 'watcher not started'
assert body['fallback_poll_ms'] == 30000
def test_probe_payload_when_watcher_instance_no_thread():
"""Probe returns 503 when watcher exists but _thread attribute is missing/None."""
class _FakeWatcher:
_thread = None
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=_FakeWatcher())
assert status == 503
assert body['watcher_running'] is False
def test_probe_payload_when_watcher_thread_alive():
"""Probe returns 200 when enabled and watcher thread is alive."""
class _FakeWatcher:
pass
w = _FakeWatcher()
t = threading.Thread(target=lambda: None)
t.daemon = True
t.start()
w._thread = t
# Thread may finish fast — loop-start a live daemon thread for reliability
import time as _time
done = threading.Event()
live = threading.Thread(target=done.wait, daemon=True)
live.start()
w._thread = live
try:
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
assert status == 200
assert body['ok'] is True
assert body['watcher_running'] is True
assert body['fallback_poll_ms'] == 30000
finally:
done.set()
live.join(timeout=1)
def test_probe_payload_when_watcher_thread_dead():
"""Probe returns 503 when watcher instance exists but thread has exited."""
class _FakeWatcher:
pass
w = _FakeWatcher()
t = threading.Thread(target=lambda: None)
t.start()
t.join() # wait for it to finish
w._thread = t
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
assert status == 503
assert body['watcher_running'] is False
assert body['ok'] is False
def test_gateway_watcher_is_alive_public_method():
"""GatewayWatcher.is_alive() is the public API the probe uses. Cover all
three states: before start(), while running, after stop()."""
from api.gateway_watcher import GatewayWatcher
w = GatewayWatcher()
# Before start(): no thread
assert w.is_alive() is False, "is_alive() must be False before start()"
# After start(): thread running
w.start()
try:
assert w.is_alive() is True, "is_alive() must be True while running"
finally:
w.stop()
# After stop(): thread cleared
assert w.is_alive() is False, "is_alive() must be False after stop()"
def test_probe_payload_prefers_public_is_alive():
"""Regression guard: _gateway_sse_probe_payload must call watcher.is_alive()
rather than poking at _thread directly when the public method exists."""
calls = []
class _WatcherWithPublicApi:
def is_alive(self):
calls.append('is_alive')
return True
# _thread is deliberately absent — must not be accessed.
body, status = _gateway_sse_probe_payload(
{'show_cli_sessions': True},
watcher=_WatcherWithPublicApi(),
)
assert status == 200
assert body['watcher_running'] is True
assert calls == ['is_alive'], (
"probe must prefer the public is_alive() method over poking _thread"
)

View File

@@ -0,0 +1,61 @@
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
def _ime_guarded_enter_pattern(event_var_pattern, require_no_shift=False):
no_shift = rf"\s*&&\s*!\s*{event_var_pattern}\.shiftKey" if require_no_shift else ""
return (
rf"if\s*\(\s*{event_var_pattern}\.key\s*===\s*'Enter'{no_shift}\s*\)\s*\{{\s*"
rf"if\s*\(\s*{event_var_pattern}\.isComposing\s*\)\s*"
rf"(?:\{{\s*return\s*;?\s*\}}|return\s*;?)"
)
def test_boot_chat_enter_send_respects_ime_composition():
assert re.search(
_ime_guarded_enter_pattern("e"),
BOOT_JS,
re.DOTALL,
), "Chat composer Enter handler must ignore IME composition Enter in static/boot.js"
assert re.search(
_ime_guarded_enter_pattern("e", require_no_shift=True),
BOOT_JS,
re.DOTALL,
), "Command dropdown Enter handler must ignore IME composition Enter in static/boot.js"
def test_ui_enter_submit_paths_respect_ime_composition():
assert re.search(
rf"document\.addEventListener\('keydown',e=>\{{[\s\S]*?{_ime_guarded_enter_pattern('e')}",
UI_JS,
re.DOTALL,
), \
"App dialog Enter handler must ignore IME composition Enter in static/ui.js"
assert re.search(
_ime_guarded_enter_pattern("e", require_no_shift=True),
UI_JS,
re.DOTALL,
), \
"Message edit Enter-to-save handler must ignore IME composition Enter in static/ui.js"
assert re.search(
rf"inp\.onkeydown=\(e2\)=>\{{\s*{_ime_guarded_enter_pattern('e2')}",
UI_JS,
re.DOTALL,
), \
"Workspace rename Enter handler must ignore IME composition Enter in static/ui.js"
def test_sessions_enter_submit_paths_respect_ime_composition():
matches = re.findall(
_ime_guarded_enter_pattern(r"e2?"),
SESSIONS_JS,
re.DOTALL,
)
assert len(matches) >= 3, \
"Session and project rename/create Enter handlers must ignore IME composition Enter in static/sessions.js"

View File

@@ -0,0 +1,198 @@
"""
Tests for issue #1014 — model-not-found error classification.
Covers:
1. streaming.py: 404/model-not-found errors detected and classified as 'model_not_found'
2. streaming.py: HTML tags stripped from provider error messages before classification
3. static/messages.js: apperror handler has model_not_found branch
4. static/i18n.js: model_not_found_label key present in all locales
5. streaming.py: model_not_found checked after auth but before generic error
"""
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
def _read(rel_path: str) -> str:
return (REPO_ROOT / rel_path).read_text(encoding="utf-8")
# ── 1. streaming.py: model-not-found error detection ─────────────────────────
class TestStreamingModelNotFoundDetection:
"""streaming.py must classify 404/model-not-found errors as model_not_found."""
def test_model_not_found_type_defined_in_streaming(self):
"""'model_not_found' type must be emitted for 404 errors."""
src = _read("api/streaming.py")
assert "model_not_found" in src, (
"model_not_found type not found in streaming.py — "
"404 errors will not be surfaced with a helpful message"
)
def test_is_not_found_flag_defined(self):
"""_exc_is_not_found variable must exist in the exception handler."""
src = _read("api/streaming.py")
assert "_exc_is_not_found" in src, (
"_exc_is_not_found flag not found in streaming.py"
)
def test_not_found_detects_404(self):
"""'404' must be part of the model-not-found detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
assert idx != -1, "_exc_is_not_found not found"
block = src[idx:idx + 600]
assert "'404'" in block or '"404"' in block, (
"'404' not in model-not-found detection block"
)
def test_not_found_detects_not_found_string(self):
"""'not found' must be part of the detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
block = src[idx:idx + 600]
assert "not found" in block.lower(), (
"'not found' not in model-not-found detection block"
)
def test_not_found_detects_does_not_exist(self):
"""'does not exist' must be part of the detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
block = src[idx:idx + 600]
assert "does not exist" in block.lower(), (
"'does not exist' not in model-not-found detection block"
)
def test_not_found_detects_invalid_model(self):
"""'invalid model' must be part of the detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
block = src[idx:idx + 600]
assert "invalid model" in block.lower(), (
"'invalid model' not in model-not-found detection block"
)
def test_not_found_hint_mentions_settings(self):
"""The model_not_found hint must mention Settings or hermes model."""
src = _read("api/streaming.py")
idx = src.find("model_not_found")
block = src[idx:idx + 500]
assert "Settings" in block or "hermes model" in block, (
"model_not_found hint must mention Settings or hermes model command"
)
def test_not_found_check_order_after_auth(self):
"""model_not_found must be checked after auth_mismatch (auth first)."""
src = _read("api/streaming.py")
auth_idx = src.find("elif _exc_is_auth")
nf_idx = src.find("elif _exc_is_not_found")
assert auth_idx != -1, "_exc_is_auth not found"
assert nf_idx != -1, "_exc_is_not_found not found"
assert auth_idx < nf_idx, (
"auth_mismatch should be checked before model_not_found — "
"auth errors must not be mistaken for not-found errors"
)
# ── 2. streaming.py: HTML sanitization ───────────────────────────────────────
class TestStreamingHtmlSanitization:
"""Provider error messages containing HTML must be stripped."""
def test_html_strip_before_classification(self):
"""HTML tags must be stripped before error classification."""
src = _read("api/streaming.py")
# Find the HTML sanitization block in the exception handler
# It should appear before _exc_lower = err_str.lower()
sanitize_idx = src.find("re.sub(r'<[^>]+>'")
exc_lower_idx = src.find("_exc_lower = err_str.lower()")
assert sanitize_idx != -1, (
"HTML tag stripping (re.sub) not found in streaming.py exception handler"
)
assert exc_lower_idx != -1, "_exc_lower not found"
assert sanitize_idx < exc_lower_idx, (
"HTML sanitization must happen before error classification"
)
def test_whitespace_normalization(self):
"""Stripped HTML must have whitespace collapsed."""
src = _read("api/streaming.py")
sanitize_idx = src.find("re.sub(r'<[^>]+>'")
block = src[sanitize_idx:sanitize_idx + 300]
assert r"\s+" in block, (
"Whitespace normalization (\\s+) not found after HTML strip"
)
# ── 3. static/messages.js: apperror handler ──────────────────────────────────
class TestApperrorModelNotFound:
"""messages.js apperror handler must handle model_not_found type."""
def test_model_not_found_type_handled(self):
"""apperror handler must check for type='model_not_found'."""
src = _read("static/messages.js")
assert "model_not_found" in src, (
"model_not_found type not handled in messages.js apperror handler"
)
def test_model_not_found_label(self):
"""'Model not found' label must appear in the error handling."""
src = _read("static/messages.js")
assert "Model not found" in src, (
"'Model not found' label not found in messages.js"
)
def test_is_model_not_found_variable(self):
"""isModelNotFound variable must be defined."""
src = _read("static/messages.js")
assert "isModelNotFound" in src, (
"isModelNotFound variable not found in messages.js apperror handler"
)
# ── 4. static/i18n.js: all locales ───────────────────────────────────────────
class TestI18nModelNotFound:
"""All locales must have model_not_found_label."""
REQUIRED_KEY = "model_not_found_label"
def _locale_names(self, src: str) -> list:
pattern = re.compile(
r"^\s{2}(?:'(?P<quoted>[A-Za-z0-9-]+)'|(?P<plain>[A-Za-z0-9-]+))\s*:\s*\{",
re.MULTILINE,
)
names = []
for match in pattern.finditer(src):
names.append(match.group("quoted") or match.group("plain"))
return names
def _count_key(self, src: str, key: str) -> int:
return len(re.findall(r'\b' + re.escape(key) + r'\b', src))
def test_all_locales_have_model_not_found_label(self):
"""model_not_found_label must appear in all locales."""
src = _read("static/i18n.js")
locale_count = len(self._locale_names(src))
count = self._count_key(src, self.REQUIRED_KEY)
assert count >= locale_count, (
f"model_not_found_label found {count} times, expected >= {locale_count} "
f"(one per locale)"
)
def test_english_label_is_plain_string(self):
"""English model_not_found_label must be a plain string, not a function."""
src = _read("static/i18n.js")
en_start = src.find("\n en: {")
es_start = src.find("\n es: {")
en_block = src[en_start:es_start]
assert self.REQUIRED_KEY in en_block, "Key not in en block"
idx = en_block.find(self.REQUIRED_KEY)
line = en_block[idx:idx + 200]
assert "=>" not in line, (
"model_not_found_label should be a plain string, not an arrow function"
)

View File

@@ -28,7 +28,7 @@ def test_msg_body_table_tr_stripe_present():
def test_msg_body_light_theme_overrides():
css = _read_css()
assert ':root[data-theme="light"] .msg-body th' in css, \
'Light-theme override for .msg-body th missing from style.css'
assert ':root[data-theme="light"] .msg-body td' in css, \
'Light-theme override for .msg-body td missing from style.css'
assert ':root:not(.dark) .msg-body th' in css, \
'Light-mode override for .msg-body th missing from style.css'
assert ':root:not(.dark) .msg-body td' in css, \
'Light-mode override for .msg-body td missing from style.css'

View File

@@ -38,15 +38,23 @@ def test_autolink_regex_in_rendermd():
def test_autolink_uses_esc_for_xss_safety():
"""The autolink code must use esc() to escape URLs, preventing XSS."""
"""The autolink code must use esc() to escape the display text of URLs, preventing XSS.
Note: esc() is intentionally NOT applied to the href value (that would corrupt & in
query strings). It IS applied to the visible link text (esc(clean)) to prevent XSS."""
content = read_ui_js()
# Find the autolink section (between the SAFE_TAGS pass and paragraph wrap)
autolink_idx = content.find('// Autolink: convert plain URLs')
assert autolink_idx != -1, "Autolink comment not found in ui.js"
# Extract the autolink block (next ~300 chars after the comment)
autolink_block = content[autolink_idx:autolink_idx + 400]
# Extract the autolink block (next ~600 chars after the comment)
autolink_block = content[autolink_idx:autolink_idx + 600]
# esc() must be used on the visible link text to prevent XSS
assert 'esc(clean)' in autolink_block, (
"Autolink block should use esc(clean) for XSS-safe URL escaping, but it was not found."
"Autolink block should use esc(clean) for the link display text (XSS safety), "
"but it was not found."
)
# esc() must NOT be used on the href value — that breaks URLs containing &
assert 'href="${esc(clean)}"' not in autolink_block, (
"Autolink block should use href=\"${clean}\" (not esc'd) to preserve & in query strings."
)
@@ -87,12 +95,13 @@ def test_autolink_target_blank_and_rel():
content = read_ui_js()
autolink_idx = content.find('// Autolink: convert plain URLs')
assert autolink_idx != -1, "Autolink comment not found"
autolink_block = content[autolink_idx:autolink_idx + 400]
# Use a larger window to account for the stash preamble added by the fix
autolink_block = content[autolink_idx:autolink_idx + 700]
assert 'target="_blank"' in autolink_block, (
"Autolinked URLs should have target=\"_blank\""
'Autolinked URLs should have target="_blank"'
)
assert 'rel="noopener"' in autolink_block, (
"Autolinked URLs should have rel=\"noopener\" for security"
'Autolinked URLs should have rel="noopener" for security'
)

348
tests/test_issue347.py Normal file
View File

@@ -0,0 +1,348 @@
"""
Tests for GitHub issue #347: KaTeX / LaTeX math rendering in chat and workspace previews.
Structural tests — no server required. Verify:
- renderMd() stashes and restores $..$ and $$...$$ math delimiters
- KaTeX lazy-load function exists and follows the mermaid pattern
- KaTeX JS loaded from CDN with SRI integrity hash
- KaTeX CSS loaded in index.html with SRI hash
- CSS rules present for .katex-block and .katex-inline
- SAFE_TAGS updated to allow <span> (for inline math)
- renderKatexBlocks() is wired into the requestAnimationFrame call
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
UI_JS = (REPO / 'static' / 'ui.js').read_text(encoding='utf-8')
INDEX = (REPO / 'static' / 'index.html').read_text(encoding='utf-8')
CSS = (REPO / 'static' / 'style.css').read_text(encoding='utf-8')
# ── renderMd pipeline ──────────────────────────────────────────────────────────
def test_display_math_stash_present():
"""renderMd must stash $$...$$ display math before other processing."""
assert r'\$\$([\s\S]+?)\$\$' in UI_JS or '$$' in UI_JS, \
'Display math $$..$$ stash regex not found in ui.js'
# The stash uses \\x00M token
assert '\\x00M' in UI_JS, 'Math stash token \\x00M not found in renderMd'
def test_inline_math_stash_present():
"""renderMd must stash $..$ inline math."""
# Inline math regex must be present
assert 'math_stash' in UI_JS, 'math_stash array not found in renderMd'
def test_katex_block_placeholder_emitted():
"""renderMd restore pass must emit .katex-block divs for display math."""
assert 'katex-block' in UI_JS, \
'.katex-block placeholder div not emitted by renderMd restore pass'
def test_katex_inline_placeholder_emitted():
"""renderMd restore pass must emit .katex-inline spans for inline math."""
assert 'katex-inline' in UI_JS, \
'.katex-inline placeholder span not emitted by renderMd restore pass'
def test_data_katex_attribute_present():
"""Placeholders must carry data-katex attribute for display/inline distinction."""
assert 'data-katex' in UI_JS, \
'data-katex attribute not found — renderKatexBlocks cannot distinguish display from inline'
# ── renderKatexBlocks() ────────────────────────────────────────────────────────
def test_render_katex_blocks_function_exists():
"""renderKatexBlocks() function must exist in ui.js."""
assert 'function renderKatexBlocks()' in UI_JS, \
'renderKatexBlocks() function not found in ui.js'
def test_katex_lazy_load_follows_mermaid_pattern():
"""KaTeX must use the same lazy-load pattern as mermaid (load on first use)."""
assert '_katexLoading' in UI_JS, '_katexLoading flag not found'
assert '_katexReady' in UI_JS, '_katexReady flag not found'
def test_katex_js_loaded_from_cdn():
"""KaTeX JS must be loaded from jsdelivr CDN."""
assert 'katex@0.16' in UI_JS, \
'KaTeX JS CDN URL not found in ui.js — expected katex@0.16.x'
def test_katex_js_has_sri_hash():
"""KaTeX JS CDN tag must have an SRI integrity hash."""
# The hash is in the script.integrity assignment
assert "script.integrity='sha384-" in UI_JS or 'script.integrity="sha384-' in UI_JS, \
'KaTeX JS SRI integrity hash not found in ui.js'
def test_katex_display_mode_used():
"""renderKatexBlocks must pass displayMode based on data-katex attribute."""
assert 'displayMode' in UI_JS, \
'displayMode not passed to katex.render() — display math will render inline'
def test_katex_throw_on_error_false():
"""KaTeX must be configured with throwOnError:false to degrade gracefully."""
assert 'throwOnError:false' in UI_JS, \
'throwOnError:false not set — bad LaTeX will throw and break the message'
def test_render_katex_blocks_wired_into_raf():
"""renderKatexBlocks() must be called in the same requestAnimationFrame as renderMermaidBlocks()."""
# Check that renderKatexBlocks appears somewhere near requestAnimationFrame
raf_idx = UI_JS.find('requestAnimationFrame')
# Find the rAF call that also contains renderKatexBlocks
has_katex_in_raf = any(
'renderKatexBlocks' in UI_JS[m.start():m.start()+200]
for m in re.finditer(r'requestAnimationFrame', UI_JS)
)
assert has_katex_in_raf, \
'renderKatexBlocks() not found in any requestAnimationFrame call — math will not render'
# ── index.html ────────────────────────────────────────────────────────────────
def test_katex_css_in_index_html():
"""KaTeX CSS must be loaded in index.html."""
assert 'katex@0.16' in INDEX, \
'KaTeX CSS CDN link not found in index.html'
def test_katex_css_has_sri_hash():
"""KaTeX CSS link in index.html must have an SRI integrity hash."""
assert 'sha384-5TcZemv2l' in INDEX or 'integrity' in INDEX and 'katex' in INDEX, \
'KaTeX CSS SRI integrity hash not found in index.html'
# ── style.css ─────────────────────────────────────────────────────────────────
def test_katex_block_css_present():
""".katex-block CSS rule must exist for centered display math."""
assert '.katex-block' in CSS, \
'.katex-block CSS rule missing from style.css — display math will have no layout'
def test_katex_inline_css_present():
""".katex-inline CSS rule must exist."""
assert '.katex-inline' in CSS, \
'.katex-inline CSS rule missing from style.css'
def test_katex_block_text_align_center():
""".katex-block must be text-align:center for display math."""
assert 'text-align:center' in CSS, \
'text-align:center not found for .katex-block'
# ── SAFE_TAGS ──────────────────────────────────────────────────────────────────
def test_safe_tags_includes_span():
"""SAFE_TAGS must include <span> to allow .katex-inline spans through the escape pass."""
# The SAFE_TAGS regex should contain 'span'
safe_tags_match = re.search(r'SAFE_TAGS\s*=\s*/.*?/i', UI_JS)
assert safe_tags_match, 'SAFE_TAGS pattern not found in ui.js'
assert 'span' in safe_tags_match.group(), \
'<span> not in SAFE_TAGS — inline math spans will be HTML-escaped and rendered as text'
# ── Stash ordering: fence must protect code spans from math extraction ─────────
WORKSPACE_JS = (REPO / 'static' / 'workspace.js').read_text(encoding='utf-8')
def test_fence_stash_before_math_stash():
"""fence_stash must be initialized and populated BEFORE math_stash in renderMd.
If math_stash runs first, dollar signs inside backtick code spans are extracted
as math, leaving placeholder tokens inside the stashed code string. The code span
then renders with KaTeX inside <code> instead of the literal dollar-sign text.
"""
fence_pos = UI_JS.find("const fence_stash=[]")
math_pos = UI_JS.find("const math_stash=[]")
assert fence_pos != -1, "fence_stash not found in renderMd"
assert math_pos != -1, "math_stash not found in renderMd"
assert fence_pos < math_pos, (
"fence_stash must be declared BEFORE math_stash in renderMd "
f"(fence at char {fence_pos}, math at char {math_pos}). "
"If math runs first, `$x$` inside backticks gets extracted as math instead of code."
)
def test_fence_stash_populated_before_math_stash():
"""The fence_stash s.replace call must appear before any math_stash s.replace calls."""
# Find the s.replace call that populates each stash
fence_replace_pos = UI_JS.find("fence_stash.push(m)")
math_replace_pos = UI_JS.find("math_stash.push(")
assert fence_replace_pos != -1, "fence_stash population call not found"
assert math_replace_pos != -1, "math_stash population call not found"
assert fence_replace_pos < math_replace_pos, (
"fence_stash must be populated before math_stash to protect code span contents"
)
def test_math_stash_comment_says_after_fence():
"""The math stash comment should explain it runs AFTER fence_stash, not before."""
# Should not have the old misleading comment
assert "Must run BEFORE fence_stash" not in UI_JS, (
"Old misleading comment still present. Math stash runs AFTER fence_stash. "
"The comment should say 'Runs AFTER fence_stash'."
)
# ── Pipeline regression: code spans protect their contents ────────────────────
def test_math_restore_after_fence_restore():
"""Math stash tokens are restored AFTER fence restore, so code spans get
their raw text back (not KaTeX placeholders)."""
fence_restore_pos = UI_JS.find("fence_stash[+i]")
math_restore_pos = UI_JS.find("math_stash[+i]")
assert fence_restore_pos != -1, "fence_stash restore not found"
assert math_restore_pos != -1, "math_stash restore not found"
# Both restores must exist; their relative order doesn't matter for correctness
# (they use different tokens: \x00F vs \x00M), but we assert both exist
assert fence_restore_pos != math_restore_pos, "fence and math restore must be separate calls"
def test_stash_tokens_distinct():
"""fence_stash and math_stash must use distinct sentinel tokens to avoid collisions."""
# fence uses \x00F, math uses \x00M (or similar unique prefix)
# The JS source uses escaped \\x00F and \\x00M as sentinel characters
# In the Python string read from the file these appear as '\\\\x00F' and '\\\\x00M'
assert "'\\\\x00F'" in UI_JS or 'x00F' in UI_JS, (
"fence stash token (\\x00F) not found — must be distinct from math token"
)
assert "'\\\\x00M'" in UI_JS or 'x00M' in UI_JS, (
"math stash token (\\x00M) not found — must be distinct from fence token"
)
# The two tokens must use different discriminator characters
assert 'x00F' in UI_JS and 'x00M' in UI_JS, (
"Both \\x00F (fence) and \\x00M (math) tokens must exist"
)
# ── Workspace preview renderKatexBlocks wiring ────────────────────────────────
def test_workspace_calls_render_katex_after_preview():
"""workspace.js must call renderKatexBlocks() after setting previewMd.innerHTML.
Without this, math placeholders appear in workspace file previews but are never
rendered by KaTeX (renderKatexBlocks is only wired into renderMessages rAF).
"""
assert "renderKatexBlocks" in WORKSPACE_JS, (
"workspace.js must call renderKatexBlocks() after renderMd() for file previews"
)
def test_workspace_renders_katex_after_file_open():
"""workspace.js renderKatexBlocks call must come after the renderMd(data.content) assignment."""
preview_md_pos = WORKSPACE_JS.find("renderMd(data.content)")
# Use the actual call string (not a stray regex match on 'M' characters)
katex_call_str = "renderKatexBlocks==='function'"
katex_call_pos = WORKSPACE_JS.find(katex_call_str)
assert preview_md_pos != -1, "renderMd(data.content) not found in workspace.js"
assert katex_call_pos != -1, (
"renderKatexBlocks guard (typeof renderKatexBlocks==='function') not found in workspace.js"
)
# The call after 'renderMd(data.content)' — find the LAST occurrence
# (there may be an earlier one in the save path at line ~153)
last_katex_pos = WORKSPACE_JS.rfind(katex_call_str)
assert last_katex_pos > preview_md_pos, (
"renderKatexBlocks must be called AFTER renderMd(data.content) in workspace.js "
f"(renderMd at {preview_md_pos}, last renderKatexBlocks at {last_katex_pos})"
)
def test_workspace_katex_guarded_by_typeof():
"""workspace.js renderKatexBlocks call must guard with typeof check for safety
in case KaTeX feature is not loaded (e.g. test environments, offline)."""
assert "typeof renderKatexBlocks" in WORKSPACE_JS, (
"workspace.js must guard renderKatexBlocks call with typeof check: "
"if(typeof renderKatexBlocks==='function')renderKatexBlocks()"
)
# ── SAFE_TAGS: span addition should not expand attack surface ─────────────────
def test_safe_tags_span_is_narrowly_scoped():
"""SAFE_TAGS adding <span> is only a bypass if span carries dangerous attributes.
Verify the SAFE_TAGS regex tests the tag NAME only, not arbitrary attributes.
The rest of the pipeline uses esc() for user content, so attribute injection
into KaTeX spans isn't possible.
"""
# The SAFE_TAGS regex must still require a word boundary / tag-end pattern
safe_tags_match = re.search(r"SAFE_TAGS\s*=\s*/(.+?)/i", UI_JS)
if not safe_tags_match:
safe_tags_match = re.search(r'SAFE_TAGS\s*=\s*/(.*?)/i', UI_JS)
assert safe_tags_match, "SAFE_TAGS regex not found"
pattern = safe_tags_match.group(1)
# Must have a trailing boundary check — ([\s>]|$) or similar
assert r"[\s>]" in pattern or r'[\s>]' in pattern, (
"SAFE_TAGS must enforce a boundary after the tag name to prevent "
"<spanxss> from matching when checking for <span>"
)
# ── False-positive prevention ─────────────────────────────────────────────────
def test_inline_math_regex_requires_non_space_boundaries():
"""The $...$ inline regex must require non-space at both boundaries.
This prevents 'costs $5 and $10' from matching — the space after the opening
$ means it's a currency amount, not math.
"""
# The inline math stash push is type:'inline' — find its containing replace() line
inline_push_idx = UI_JS.find("type:'inline',src:m")
assert inline_push_idx != -1, "Inline math stash push not found"
# Get the text from the start of that line back to find the regex
line_start = UI_JS.rfind('\n', 0, inline_push_idx) + 1
inline_line = UI_JS[line_start:inline_push_idx + 50]
# The regex must use \s (via [^\s...]) to exclude spaces at boundaries
assert '\\s' in inline_line or '[^' in inline_line, (
f"Inline math regex must exclude spaces at boundaries to prevent false "
f"positives on currency like $5. Found: {inline_line[:120]}"
)
def test_display_math_stashed_before_inline():
"""$$...$$ display math must be stashed before $...$ inline math.
If inline runs first on '$$x$$', it could match '$' + 'x' + '$' leaving
a stray outer '$', corrupting the output.
"""
display_pos = UI_JS.find("type:'display',src:m")
inline_pos = UI_JS.find("type:'inline',src:m")
assert display_pos != -1, "display math stash not found"
assert inline_pos != -1, "inline math stash not found"
# First occurrence of display must be before first occurrence of inline
assert display_pos < inline_pos, (
"Display math ($$...$$) must be stashed before inline math ($...$) "
"to prevent $$ from being parsed as two adjacent inline delimiters"
)
def test_math_stash_token_uses_single_backslash_null_byte():
"""Math stash tokens must use the null-byte form (single backslash x00M).
The restore regex expects a null byte character. If the stash emits
a literal backslash+x00M (double backslash = 5-char string), the restore
regex never matches and the tokens appear verbatim in the rendered output.
The fence_stash correctly uses the null byte convention. Math stash must be consistent.
"""
# In the source file, the correct form is: return '\x00M'
# The wrong form (double backslash) would be: return '\\x00M'
# Check that no double-backslash form exists in the math stash return statements
import re
bad_returns = re.findall(r"return\s+'\\\\x00M'", UI_JS)
assert not bad_returns, (
f"Found {len(bad_returns)} math stash return(s) using double-backslash \\\\x00M. "
"Must use single backslash '\x00M' (null byte) to match the restore regex."
)
# Positive check: single-backslash form must exist
good_returns = re.findall(r"math_stash\.push.*?return '\\x00M'", UI_JS, re.DOTALL)
assert good_returns, (
"Math stash return must use single-backslash '\x00M' (null byte convention)"
)

202
tests/test_issue357.py Normal file
View File

@@ -0,0 +1,202 @@
"""
Tests for GitHub issue #357: Docker container fails to start without internet access.
Structural tests — verify Dockerfile and docker_init.bash contain the expected
patterns for pre-installed uv and workspace permission fixes.
Two problems fixed:
1. uv was downloaded at container startup; fails in air-gapped / firewalled environments.
Fix: pre-install uv in the Docker image at build time (system-wide in /usr/local/bin).
2. workspace directory created with plain mkdir (as root); bind-mount dirs created by
Docker as root are unwritable by the hermeswebui user.
Fix: sudo mkdir + sudo chown for workspace directory.
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
DOCKERFILE = (REPO / "Dockerfile").read_text(encoding="utf-8")
INIT_SCRIPT = (REPO / "docker_init.bash").read_text(encoding="utf-8")
# ── Dockerfile: uv pre-installed at build time ───────────────────────────────
class TestDockerfileUvPreinstall:
def test_dockerfile_installs_uv_at_build_time(self):
"""Dockerfile must install uv via RUN curl at build time (not only at runtime)."""
assert "RUN curl" in DOCKERFILE and "uv/install.sh" in DOCKERFILE, (
"Dockerfile must install uv at build time via RUN curl .../uv/install.sh"
)
def test_dockerfile_uv_installed_system_wide(self):
"""uv must be installed to a system-wide directory (/usr/local/bin) accessible
to all users, not to a user-specific ~/.local/bin that another user can't see."""
# The install command must target /usr/local/bin or use root to install globally
uv_install_line = next(
(line for line in DOCKERFILE.splitlines() if "uv/install.sh" in line),
None,
)
assert uv_install_line is not None, "Could not find uv install line in Dockerfile"
# Must either use UV_INSTALL_DIR pointing to /usr/local/bin, or run as root
# (so the default install location is accessible to hermeswebui user)
has_system_dir = "/usr/local/bin" in uv_install_line or "UV_INSTALL_DIR=/usr/local/bin" in DOCKERFILE
assert has_system_dir, (
"uv must be installed to /usr/local/bin (system-wide) so hermeswebui user "
"can find it. Installing as hermeswebuitoo puts it in /home/hermeswebuitoo/.local/bin "
"which is NOT on hermeswebui's PATH."
)
def test_dockerfile_uv_installed_before_copy(self):
"""uv installation must happen before COPY . /apptoo so it's in the image."""
import re
uv_pos = DOCKERFILE.find("uv/install.sh")
# Match COPY regardless of flags (e.g. --chown=...) — only the destination matters.
m = re.search(r"^COPY\b.*\s/apptoo\b", DOCKERFILE, re.MULTILINE)
assert uv_pos != -1, "uv install not found in Dockerfile"
assert m is not None, "COPY ... /apptoo not found in Dockerfile"
copy_pos = m.start()
assert uv_pos < copy_pos, "uv must be installed before COPY . /apptoo"
def test_dockerfile_uv_installed_as_root_or_before_user_switch(self):
"""uv must be installed as root (USER root) to reach /usr/local/bin.
If installed as hermeswebuitoo, it lands in ~hermeswebuitoo/.local/bin,
which the hermeswebui user at runtime can't see.
"""
lines = DOCKERFILE.splitlines()
uv_line_idx = next(i for i, l in enumerate(lines) if "uv/install.sh" in l)
# Find the last USER directive before the uv install line
user_before = None
for i in range(uv_line_idx - 1, -1, -1):
if lines[i].strip().startswith("USER "):
user_before = lines[i].strip().split()[1]
break
assert user_before == "root", (
f"uv install must run as USER root (found USER {user_before!r}). "
"Installing as hermeswebuitoo puts uv in /home/hermeswebuitoo/.local/bin "
"which is not accessible to the hermeswebui runtime user."
)
# ── docker_init.bash: skip uv download when already present ─────────────────
class TestInitScriptUvSkip:
def test_init_script_checks_uv_before_download(self):
"""docker_init.bash must check 'command -v uv' before attempting download."""
assert "command -v uv" in INIT_SCRIPT, (
"docker_init.bash must check 'command -v uv' to skip download "
"when uv is already pre-installed in the image (#357)"
)
def test_init_script_skips_download_if_present(self):
"""Init script must use conditional logic (if/else) around the uv download."""
# Pattern: if command -v uv ... else ... fi
assert re.search(r'if\s+command\s+-v\s+uv', INIT_SCRIPT), (
"docker_init.bash must use 'if command -v uv' guard around the download"
)
def test_init_script_curl_download_in_else_branch(self):
"""The curl download must be in the else branch (only runs if uv not found)."""
# Find the conditional block
m = re.search(
r'if\s+command\s+-v\s+uv.*?fi',
INIT_SCRIPT, re.DOTALL
)
assert m, "Could not find uv conditional block in docker_init.bash"
block = m.group(0)
# curl must appear after 'else' not in the 'then' branch
else_pos = block.find("else")
curl_pos = block.find("curl")
assert else_pos != -1, "No 'else' branch in uv conditional"
assert curl_pos != -1, "No 'curl' in uv conditional block"
assert curl_pos > else_pos, (
"curl download must be in the 'else' branch, not the 'if/then' branch"
)
def test_init_script_error_exit_on_download_failure(self):
"""Curl download must call error_exit on failure (not silently continue)."""
assert "error_exit" in INIT_SCRIPT and "Failed to install uv" in INIT_SCRIPT, (
"docker_init.bash must call error_exit if uv download fails, "
"so the container exits with a clear message instead of failing silently"
)
def test_init_script_path_includes_hermeswebui_local_bin(self):
"""PATH must include /home/hermeswebui/.local/bin for fallback runtime install."""
assert "/home/hermeswebui/.local/bin" in INIT_SCRIPT, (
"docker_init.bash must include /home/hermeswebui/.local/bin in PATH "
"for the case where uv is installed at runtime via curl"
)
# ── docker_init.bash: workspace directory permissions ────────────────────────
class TestWorkspacePermissions:
def test_workspace_uses_sudo_mkdir(self):
"""docker_init.bash must use 'sudo mkdir' for the workspace directory.
Docker auto-creates bind-mount directories as root if they don't exist,
leaving them unwritable by hermeswebui. sudo mkdir + chown fixes this.
"""
# Find the workspace section
ws_section = INIT_SCRIPT[
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE"):
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE") + 800
]
assert "sudo mkdir" in ws_section, (
"docker_init.bash must use 'sudo mkdir -p' for the workspace directory "
"to handle the case where Docker created the bind-mount dir as root (#357)"
)
def test_workspace_uses_sudo_chown(self):
"""docker_init.bash must chown the workspace to hermeswebui when writable.
The chown is now conditional on the workspace being writable, to allow
read-only (:ro) workspace mounts without crashing (#670). The sudo chown
must still be present in the script (just guarded by [ -w ]).
"""
assert 'sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"' in INIT_SCRIPT, (
"docker_init.bash must 'sudo chown hermeswebui:hermeswebui' the workspace "
"when it is writable, so the app user can write to it (#357)"
)
def test_workspace_mkdir_before_chown(self):
"""sudo mkdir must come before sudo chown in docker_init.bash."""
mkdir_pos = INIT_SCRIPT.find('sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE"')
chown_pos = INIT_SCRIPT.find('sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"')
assert mkdir_pos != -1, "sudo mkdir for workspace not found"
assert chown_pos != -1, "sudo chown for workspace not found"
assert mkdir_pos < chown_pos, "sudo mkdir must come before sudo chown"
def test_workspace_error_exit_on_mkdir_failure(self):
"""sudo mkdir must call error_exit on failure."""
assert 'sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit' in INIT_SCRIPT, (
"sudo mkdir for workspace must call error_exit on failure"
)
def test_workspace_chown_is_conditional_on_writable(self):
"""chown and write-test must be skipped for read-only workspace mounts (#670).
The script must check [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ] before
attempting chown or a write test, so :ro bind-mounts don't crash startup.
"""
assert '[ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]' in INIT_SCRIPT, (
"docker_init.bash must guard chown with [ -w ] to support read-only "
"workspace mounts (:ro) without crashing (#670)"
)
# Read-only path must log a clear message rather than calling error_exit
assert "read-only workspace is supported" in INIT_SCRIPT, (
"docker_init.bash must print a clear message when workspace is read-only (#670)"
)
def test_init_script_syntax_valid(self):
"""docker_init.bash must pass bash -n syntax check."""
import subprocess
result = subprocess.run(
["bash", "-n", str(REPO / "docker_init.bash")],
capture_output=True, text=True
)
assert result.returncode == 0, (
f"docker_init.bash failed bash -n syntax check:\n{result.stderr}"
)

116
tests/test_issue401.py Normal file
View File

@@ -0,0 +1,116 @@
"""
Regression tests for tool-card persistence on session reload.
The older loadSession() path rewrote message history on the client:
- dropped role='tool' rows
- dropped empty assistant rows even when they carried tool_calls
- then ignored session.tool_calls on reload
That broke both durable logging and page refresh for valid tool runs.
"""
import json
import pathlib
import subprocess
import textwrap
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
def test_loadsession_preserves_tool_rows():
"""Reload must keep tool rows in S.messages so snippets can be reconstructed."""
assert "if (m.role === 'tool') continue;" not in SESSIONS_JS, (
"loadSession() must not drop role='tool' messages; renderMessages() hides them "
"visually, but it still needs them for snippet reconstruction"
)
def test_loadsession_uses_session_toolcalls_only_as_fallback():
"""Session summaries are the fallback, not the primary reload source."""
assert ("if(!hasMessageToolMetadata&&data.session.tool_calls&&data.session.tool_calls.length)" in SESSIONS_JS or
"if (!hasMessageToolMetadata && data.session.tool_calls && data.session.tool_calls.length)" in SESSIONS_JS)
assert ("S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));" in SESSIONS_JS or
"S.toolCalls = data.session.tool_calls.map(tc => ({...tc, done: true}));" in SESSIONS_JS)
assert "S.toolCalls=[];" in SESSIONS_JS
def test_rendermessages_treats_openai_toolcall_assistants_as_visible():
"""OpenAI assistant rows with empty content but tool_calls must stay anchorable."""
assert "const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;" in UI_JS
assert "if(hasTc||hasTu||_messageHasReasoningPayload(m)) return true;" in UI_JS
def _run_js(script_body: str) -> dict:
script = textwrap.dedent(f"""
function loadSessionShape(messages, sessionToolCalls) {{
const filtered = (messages || []).filter(m => m && m.role);
const hasMessageToolMetadata = filtered.some(m => {{
if (!m || m.role !== 'assistant') return false;
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
const hasTu = Array.isArray(m.content) && m.content.some(p => p && p.type === 'tool_use');
return hasTc || hasTu;
}});
const toolCalls = (!hasMessageToolMetadata && sessionToolCalls && sessionToolCalls.length)
? sessionToolCalls.map(tc => ({{ ...tc, done: true }}))
: [];
return {{ filtered, hasMessageToolMetadata, toolCalls }};
}}
{script_body}
""")
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(proc.stdout)
def test_reload_keeps_empty_assistant_toolcall_anchor():
"""OpenAI-style assistant {content:'', tool_calls:[...]} must survive reload."""
result = _run_js("""
const messages = [
{ role: 'user', content: 'list files' },
{
role: 'assistant',
content: '',
tool_calls: [{ id: 'call-1', function: { name: 'terminal', arguments: '{}' } }]
},
{ role: 'tool', tool_call_id: 'call-1', content: '{"output":"ok"}' },
{ role: 'assistant', content: 'Done.' }
];
const loaded = loadSessionShape(messages, [{ name: 'terminal', assistant_msg_idx: 1 }]);
process.stdout.write(JSON.stringify({
filtered_len: loaded.filtered.length,
has_metadata: loaded.hasMessageToolMetadata,
fallback_len: loaded.toolCalls.length,
assistant_tool_idx: loaded.filtered.findIndex(m => m.role === 'assistant' && m.tool_calls),
tool_idx: loaded.filtered.findIndex(m => m.role === 'tool')
}));
""")
assert result["filtered_len"] == 4
assert result["has_metadata"] is True
assert result["fallback_len"] == 0
assert result["assistant_tool_idx"] == 1
assert result["tool_idx"] == 2
def test_reload_uses_session_summary_when_messages_have_no_tool_metadata():
"""Older sessions should still render from session.tool_calls on reload."""
result = _run_js("""
const messages = [
{ role: 'user', content: 'build site' },
{ role: 'assistant', content: 'Starting.' },
{ role: 'tool', content: '{"bytes_written": 4955}' },
{ role: 'assistant', content: '' }
];
const sessionToolCalls = [
{ name: 'write_file', assistant_msg_idx: 1, snippet: 'bytes_written', tid: '' }
];
const loaded = loadSessionShape(messages, sessionToolCalls);
process.stdout.write(JSON.stringify({
has_metadata: loaded.hasMessageToolMetadata,
fallback_len: loaded.toolCalls.length,
done_flag: loaded.toolCalls[0] && loaded.toolCalls[0].done === true
}));
""")
assert result["has_metadata"] is False
assert result["fallback_len"] == 1
assert result["done_flag"] is True

313
tests/test_issue470.py Normal file
View File

@@ -0,0 +1,313 @@
"""
Tests for issue #470 — markdown link rendering bugs in renderMd():
1. Double-linking: [label](url) converted to <a>, then autolink re-matches
the URL inside href="..." and wraps it in a second <a>.
2. esc() applied to URLs in href attributes turns & → &amp;, breaking
URLs with query strings and producing &amp; in displayed link text.
3. Same double-linking bug inside table cells via inlineMd().
These tests verify the fixes by asserting against the rendered HTML that
ui.js serves, using a live server request to evaluate the actual JS output
indirectly (via checking ui.js source for the fixed patterns) AND by
running a lightweight Python mirror of the fixed renderMd logic.
Strategy: verify the fix is present in the JS source, then test the
expected rendering behaviour through the Python mirror.
"""
import pathlib
import re
import html as _html
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text()
# ── Helpers ──────────────────────────────────────────────────────────────────
def esc(s):
return _html.escape(str(s), quote=True)
def _make_link(url, label):
"""Expected output for a [label](url) link after fix: href is NOT esc()-ed."""
return f'<a href="{url}" target="_blank" rel="noopener">{esc(label)}</a>'
# Minimal Python mirror of the FIXED renderMd() — enough to test link behaviour.
# Mirrors the stash-based approach introduced by the fix.
def render_links_only(text):
"""
Simplified render that only applies the link-related passes from the fixed
renderMd(): [label](url) conversion + autolink, with the stash protection.
Sufficient for testing that links render correctly without double-linking.
"""
s = text
# Stash [label](url) links (fix: store href as raw URL, not esc(url))
link_stash = []
def stash_link(m):
label, url = m.group(1), m.group(2)
link_stash.append(f'<a href="{url}" target="_blank" rel="noopener">{esc(label)}</a>')
return f'\x00L{len(link_stash)-1}\x00'
s = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_link, s)
# Autolink bare URLs (should NOT match inside already-stashed placeholders)
def autolink(m):
url = m.group(1)
trail = url[-1] if url[-1] in '.,;:!?)' else ''
clean = url[:-1] if trail else url
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
s = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, s)
# Restore stashed links
s = re.sub(r'\x00L(\d+)\x00', lambda m: link_stash[int(m.group(1))], s)
return s
def render_table_with_links(md):
"""
Render a markdown table that may contain [label](url) cells.
Mirrors the fixed inlineMd() + table rendering.
"""
lines = md.strip().split('\n')
if len(lines) < 2:
return md
def is_sep(r):
return bool(re.match(r'^\|[\s|:-]+\|$', r.strip()))
if not is_sep(lines[1]):
return md
def inline_md_fixed(t):
"""Fixed inlineMd: stash links before autolink."""
stash = []
def stash_fn(m):
lb, u = m.group(1), m.group(2)
stash.append(f'<a href="{u}" target="_blank" rel="noopener">{esc(lb)}</a>')
return f'\x00L{len(stash)-1}\x00'
t = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_fn, t)
# autolink remaining bare URLs
def autolink(m):
url = m.group(1)
trail = url[-1] if url[-1] in '.,;:!?)' else ''
clean = url[:-1] if trail else url
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
t = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, t)
t = re.sub(r'\x00L(\d+)\x00', lambda m: stash[int(m.group(1))], t)
return t
def parse_row(r):
cells = r.strip().lstrip('|').rstrip('|').split('|')
return ''.join(f'<td>{inline_md_fixed(c.strip())}</td>' for c in cells)
def parse_header(r):
cells = r.strip().lstrip('|').rstrip('|').split('|')
return ''.join(f'<th>{inline_md_fixed(c.strip())}</th>' for c in cells)
header = f'<tr>{parse_header(lines[0])}</tr>'
body = ''.join(f'<tr>{parse_row(r)}</tr>' for r in lines[2:])
return f'<table><thead>{header}</thead><tbody>{body}</tbody></table>'
# ── Source-level checks (verify fix is in the JS) ─────────────────────────────
def test_inlinemd_uses_link_stash():
"""Fixed inlineMd() must stash [label](url) links before autolink runs."""
assert '_link_stash' in UI_JS, (
"inlineMd() should use _link_stash to prevent double-linking"
)
def test_inlinemd_no_esc_on_href():
"""Fixed inlineMd() must not call esc() on the URL in href."""
# The old broken pattern had esc(u) inside the href
assert 'href="${esc(u)}"' not in UI_JS, (
"inlineMd() should not call esc() on href URL — it breaks & in query strings"
)
def test_outer_link_pass_uses_a_stash():
"""Fixed outer link pass must stash existing <a> tags before running."""
assert '_a_stash' in UI_JS, (
"Outer [label](url) pass should stash existing <a> tags to prevent autolink re-matching"
)
def test_autolink_pass_uses_al_stash():
"""Fixed autolink pass must stash existing <a> tags before running."""
assert '_al_stash' in UI_JS, (
"Autolink pass should stash existing <a> tags to prevent double-linking"
)
def test_autolink_no_esc_on_href():
"""Fixed autolink pass must not call esc() on href URL."""
idx = UI_JS.find('// Autolink: convert plain URLs to clickable links.')
assert idx != -1, "New autolink comment not found"
autolink_section = UI_JS[idx:idx+600]
# The return line should have href="${clean}" (JS template literal, no esc call)
assert 'href="${clean}"' in autolink_section, (
'Autolink should use href="${clean}" not href="${esc(clean)}"'
)
assert 'href="${esc(clean)}"' not in autolink_section, (
"Autolink should not esc() the URL in href"
)
# ── Behaviour tests (Python mirror of fixed renderMd) ─────────────────────────
def test_labeled_link_renders_as_single_anchor():
"""[#461](https://github.com/.../461) must produce exactly one <a> tag."""
url = 'https://github.com/nesquena/hermes-webui/issues/461'
md = f'[#461]({url})'
result = render_links_only(md)
assert result.count('<a ') == 1, f"Expected 1 <a> tag, got: {result}"
assert result.count('</a>') == 1
assert f'href="{url}"' in result
assert '#461' in result
# Must not contain the raw brackets
assert '[#461]' not in result
assert f']({url})' not in result
def test_href_not_html_escaped():
"""URLs with & must appear as literal & in href, not &amp;."""
url = 'https://example.com/search?q=foo&bar=baz'
md = f'[Search]({url})'
result = render_links_only(md)
assert f'href="{url}"' in result, (
f"& in URL should not be escaped to &amp; in href. Got: {result}"
)
assert '&amp;' not in result
def test_bare_url_not_double_linked():
"""A bare https:// URL must produce exactly one <a> tag."""
url = 'https://github.com/nesquena/hermes-webui/issues/461'
result = render_links_only(url)
assert result.count('<a ') == 1, f"Expected 1 <a> tag, got: {result}"
assert result.count('</a>') == 1
def test_labeled_link_in_table_cell_single_anchor():
"""[#461](url) inside a markdown table cell must produce exactly one <a> tag."""
url = 'https://github.com/nesquena/hermes-webui/issues/461'
md = f'| Issue | Title |\n|---|---|\n| [#461]({url}) | Reasoning effort |'
result = render_table_with_links(md)
assert result.count('<a ') == 1, f"Expected 1 <a> in table, got: {result}"
assert f'href="{url}"' in result
assert '#461' in result
# No raw brackets should appear in output
assert '[#461]' not in result
def test_multiple_links_in_table_no_double_linking():
"""Multiple [label](url) links in a table must each produce exactly one <a>."""
urls = [
'https://github.com/nesquena/hermes-webui/issues/461',
'https://github.com/nesquena/hermes-webui/issues/462',
'https://github.com/nesquena/hermes-webui/issues/463',
]
rows = '\n'.join(f'| [#{461+i}]({url}) | Title {i} |' for i, url in enumerate(urls))
md = f'| Issue | Title |\n|---|---|\n{rows}'
result = render_table_with_links(md)
assert result.count('<a ') == 3, f"Expected 3 <a> tags, got {result.count('<a ')}:\n{result}"
assert result.count('</a>') == 3
for url in urls:
assert f'href="{url}"' in result
def test_link_label_is_escaped():
"""The label text (not the URL) must still be HTML-escaped."""
url = 'https://example.com'
md = f'[Click <here>]({url})'
result = render_links_only(md)
assert '&lt;here&gt;' in result, "Label text should be HTML-escaped"
assert '<here>' not in result
def test_link_not_broken_by_prior_autolink():
"""A [label](url) followed by a bare URL must each produce one clean <a>."""
url1 = 'https://github.com/issues/461'
url2 = 'https://github.com/issues/462'
md = f'See [#461]({url1}) and also {url2}'
result = render_links_only(md)
assert result.count('<a ') == 2, f"Expected 2 links, got: {result}"
assert f'href="{url1}"' in result
assert f'href="{url2}"' in result
assert '#461' in result
def test_href_quote_sanitized():
"""A URL containing a double-quote must have it percent-encoded in href to prevent attribute breakout."""
# This would break out of href="..." and inject an event handler without the fix
url = 'https://evil.com" onmouseover="alert(1)'
# The [label](url) regex captures up to the closing ), so we test via the render helper
# by constructing a URL that contains a literal quote character
safe_url = 'https://example.com/path"with"quotes'
result = render_links_only(f'[click]({safe_url})')
# The href must not contain a raw unencoded double-quote
href_start = result.find('href="') + 6
href_end = result.find('"', href_start)
href_val = result[href_start:href_end]
assert '"' not in href_val, (
f"href value must not contain unencoded double-quote. Got href: {href_val}"
)
def test_js_source_sanitizes_quotes_in_href():
"""JS source must apply quote percent-encoding to URLs before placing in href."""
# Both the inlineMd stash and outer link pass must sanitize quotes
assert "%22" in UI_JS, (
"URL placed in href should have double-quotes percent-encoded via .replace to %22"
)
# ── Code-inside-bold tests (pre-existing bug, fixed in same PR) ───────────────
def test_js_inlinemd_stashes_code_before_bold():
"""Fixed inlineMd() must stash backtick code spans before bold/italic processing."""
assert '_code_stash' in UI_JS, (
"inlineMd() should use _code_stash to protect backtick spans from bold/italic esc()"
)
def test_code_inside_bold_renders_correctly():
"""Inline code inside bold text must render as <strong><code>...</code></strong>,
not with escaped &lt;code&gt; tags visible on screen."""
# This was the pre-existing bug: **`esc()`** → <strong>&lt;code&gt;esc()&lt;/code&gt;</strong>
text = '**`esc()` on `href`**: breaks URLs'
# Simulate the fixed inlineMd()
code_stash = []
t = text
t = re.sub(r'`([^`\n]+)`',
lambda m: (code_stash.append(f'<code>{esc(m.group(1))}</code>') or f'\x00C{len(code_stash)-1}\x00'), t)
t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'<strong>{esc(m.group(1))}</strong>', t)
t = re.sub(r'\x00C(\d+)\x00', lambda m: code_stash[int(m.group(1))], t)
assert '&lt;code&gt;' not in t, (
f"Code tags should not be HTML-escaped inside bold. Got: {t}"
)
assert '<code>esc()</code>' in t, (
f"Code tags should render as <code> elements inside bold. Got: {t}"
)
assert '<strong>' in t, "Bold should still render"
def test_code_and_bold_mixed_no_escaping():
"""Bold text containing multiple backtick spans must render all code tags correctly."""
cases = [
('**`esc()` on `href`**', '<strong>', '<code>esc()</code>', '<code>href</code>'),
('***`code` in bold-italic***', '<strong>', '<code>code</code>'),
('`code` then **bold**', '<code>code</code>', '<strong>bold</strong>'),
]
for args in cases:
text = args[0]
expected_fragments = args[1:]
code_stash = []
t = text
t = re.sub(r'`([^`\n]+)`',
lambda m: (code_stash.append(f'<code>{esc(m.group(1))}</code>') or f'\x00C{len(code_stash)-1}\x00'), t)
t = re.sub(r'\*\*\*(.+?)\*\*\*', lambda m: f'<strong><em>{esc(m.group(1))}</em></strong>', t)
t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'<strong>{esc(m.group(1))}</strong>', t)
t = re.sub(r'\x00C(\d+)\x00', lambda m: code_stash[int(m.group(1))], t)
assert '&lt;code&gt;' not in t, f"Escaped code tag in: {text!r}{t}"
for frag in expected_fragments:
assert frag in t, f"Expected {frag!r} in output of {text!r}, got: {t}"

26
tests/test_issue477.py Normal file
View File

@@ -0,0 +1,26 @@
"""Tests for fix #477: KaTeX font-src CSP fix."""
import pathlib
REPO = pathlib.Path(__file__).parent.parent
HELPERS_PY = (REPO / "api" / "helpers.py").read_text(encoding="utf-8")
def test_font_src_allows_jsdelivr():
"""font-src must include cdn.jsdelivr.net for KaTeX fonts."""
assert "font-src 'self' data: https://cdn.jsdelivr.net" in HELPERS_PY, (
"api/helpers.py CSP must allow cdn.jsdelivr.net in font-src "
"so KaTeX math rendering fonts load without console errors."
)
def test_font_src_still_allows_self_and_data():
"""font-src must still allow self and data: (used by other font assets)."""
assert "'self'" in HELPERS_PY.split("font-src")[1].split(";")[0]
assert "data:" in HELPERS_PY.split("font-src")[1].split(";")[0]
def test_script_src_already_allows_jsdelivr():
"""script-src already allows cdn.jsdelivr.net — font-src should too."""
assert "https://cdn.jsdelivr.net" in HELPERS_PY.split("font-src")[0], (
"script-src should already allow cdn.jsdelivr.net (KaTeX JS)"
)

572
tests/test_issue486_487.py Normal file
View File

@@ -0,0 +1,572 @@
"""
Tests for issue #486 (CSS: inline code in table cells) and
issue #487 (JS renderer: markdown image syntax not implemented).
Issue #486 — CSS fix in static/style.css:
Inline `code` spans inside table cells render with awkward sizing.
Fix: td code, th code { font-size: 0.85em; padding: 1px 4px; vertical-align: baseline; }
Issue #487 — JS fix in static/ui.js:
![alt](url) image syntax not handled — renders as stray ! + link.
Fix: add image pass to renderMd() (before link pass) and inlineMd()
reusing the .msg-media-img class.
Strategy:
- Source-level checks verify the fixes are present in the JS/CSS.
- Python mirror tests verify the rendering logic with exhaustive edge cases,
especially code blocks inside tables (the specific case Nathan flagged).
"""
import pathlib
import re
import html as _html
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text()
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text()
# ── Helpers ───────────────────────────────────────────────────────────────────
def esc(s):
return _html.escape(str(s), quote=True)
def inline_md(t):
"""
Python mirror of the fixed inlineMd() function — includes:
- _code_stash (protects backtick spans from bold/italic AND from image pass)
- image pass (NEW for #487 — runs while code stash is active, before link pass)
- _img_stash (protects rendered img tags from autolink touching src=)
- _link_stash (protects links from autolink)
- autolink
- code stash restore (after autolink, so code content is never autolinked)
Correct operation order:
1. code stash — \x00C protects `...` from bold and image pass
2. bold/italic — runs on plain text only
3. image pass — runs while code content is still stashed (so ![x](url)
inside backticks stays protected as a \x00C token)
4. img stash — \x00I protects <img src="url"> from autolink
5. link stash — \x00L protects [label](url) links from autolink
6. autolink — only matches URLs not already in a stash token
7. link stash restore
8. img stash restore
9. code stash restore — restores <code> tags last
"""
# 1. Code stash — must be first to protect code content from all subsequent passes
code_stash = []
def stash_code(m):
code_stash.append(f'<code>{esc(m.group(1))}</code>')
return f'\x00C{len(code_stash)-1}\x00'
t = re.sub(r'`([^`\n]+)`', stash_code, t)
# 2. Bold/italic (code content is safely stashed)
t = re.sub(r'\*\*\*(.+?)\*\*\*', lambda m: f'<strong><em>{esc(m.group(1))}</em></strong>', t)
t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'<strong>{esc(m.group(1))}</strong>', t)
t = re.sub(r'\*([^*\n]+)\*', lambda m: f'<em>{esc(m.group(1))}</em>', t)
# 3. Image pass (NEW — runs while code is still stashed, so ![x](url) inside
# backticks is protected as a \x00C token and won't match here)
def render_image(m):
alt, url = m.group(1), m.group(2)
safe_url = url.replace('"', '%22')
return (f'<img src="{safe_url}" alt="{esc(alt)}" '
f'class="msg-media-img" loading="lazy" '
f'onclick="this.classList.toggle(\'msg-media-img--full\')">')
t = re.sub(r'!\[([^\]]*)\]\((https?://[^\)]+)\)', render_image, t)
# 4. Img stash — protect rendered <img> tags so autolink never touches src= values
img_stash = []
def stash_img(m):
img_stash.append(m.group(0))
return f'\x00I{len(img_stash)-1}\x00'
t = re.sub(r'<img\b[^>]*>', stash_img, t)
# 5. Link stash
link_stash = []
def stash_link(m):
lb, u = m.group(1), m.group(2)
link_stash.append(f'<a href="{u.replace(chr(34), "%22")}" target="_blank" rel="noopener">{esc(lb)}</a>')
return f'\x00L{len(link_stash)-1}\x00'
t = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_link, t)
# 6. Autolink (img and link URLs are both stashed — safe)
def autolink(m):
url = m.group(1)
trail = url[-1] if url[-1] in '.,;:!?)' else ''
clean = url[:-1] if trail else url
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
t = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, t)
# 7. Restore link stash
t = re.sub(r'\x00L(\d+)\x00', lambda m: link_stash[int(m.group(1))], t)
# 8. Restore img stash
t = re.sub(r'\x00I(\d+)\x00', lambda m: img_stash[int(m.group(1))], t)
# 9. Restore code stash (last — code content was never touched by any pass)
t = re.sub(r'\x00C(\d+)\x00', lambda m: code_stash[int(m.group(1))], t)
return t
def render_table(md):
"""Python mirror of the table pass, using inline_md() per cell."""
lines = md.strip().split('\n')
if len(lines) < 2:
return md
def is_sep(r):
return bool(re.match(r'^\|[\s|:-]+\|$', r.strip()))
if not is_sep(lines[1]):
return md
def parse_header(r):
cells = r.strip().lstrip('|').rstrip('|').split('|')
return ''.join(f'<th>{inline_md(c.strip())}</th>' for c in cells)
def parse_row(r):
cells = r.strip().lstrip('|').rstrip('|').split('|')
return ''.join(f'<td>{inline_md(c.strip())}</td>' for c in cells)
header = f'<tr>{parse_header(lines[0])}</tr>'
body = ''.join(f'<tr>{parse_row(r)}</tr>' for r in lines[2:])
return f'<table><thead>{header}</thead><tbody>{body}</tbody></table>'
# ═════════════════════════════════════════════════════════════════════════════
# ISSUE #486 — CSS: code inside table cells
# ═════════════════════════════════════════════════════════════════════════════
class TestIssue486CssCodeInTable:
"""CSS fix: td code and th code must have targeted sizing rules."""
def test_td_code_font_size_present(self):
"""msg-body td code rule must set font-size (e.g. 0.85em) to prevent oversized code."""
assert 'td code' in STYLE_CSS, (
"Missing 'td code' CSS rule — inline code in table cells needs sizing fix"
)
def test_th_code_rule_present(self):
"""th code rule must also exist for header cells."""
assert 'th code' in STYLE_CSS, (
"Missing 'th code' CSS rule — inline code in header cells needs sizing fix"
)
def test_td_code_has_font_size(self):
"""The td code / th code block must include a font-size declaration."""
# Find the msg-body scoped td code rule
idx = STYLE_CSS.find('td code')
assert idx != -1, "td code rule not found in style.css"
# Check nearby text (within 200 chars) has font-size
window = STYLE_CSS[idx:idx+200]
assert 'font-size' in window, (
f"td code rule must include font-size. Found near td code: {window!r}"
)
def test_td_code_has_padding(self):
"""The td code / th code block must include a padding declaration."""
idx = STYLE_CSS.find('td code')
assert idx != -1
window = STYLE_CSS[idx:idx+200]
assert 'padding' in window, (
f"td code rule must include padding. Found near td code: {window!r}"
)
def test_td_code_has_vertical_align(self):
"""The td code / th code block must include vertical-align: baseline."""
idx = STYLE_CSS.find('td code')
assert idx != -1
window = STYLE_CSS[idx:idx+200]
assert 'vertical-align' in window, (
f"td code rule must include vertical-align. Found near td code: {window!r}"
)
def test_code_renders_inside_table_cell(self):
"""Inline `code` inside a table cell must render as <code> element."""
md = "| Syntax | Rendered |\n|---|---|\n| `code` | `code` |"
result = render_table(md)
assert '<code>code</code>' in result, (
f"Inline code in table cell should render as <code>. Got: {result}"
)
def test_bold_code_renders_inside_table_cell(self):
"""**`bold code`** inside a table cell must render as <strong><code>."""
md = "| Style | Example |\n|---|---|\n| bold code | **`bold code`** |"
result = render_table(md)
# Should have code tag (even inside bold)
assert '<code>bold code</code>' in result, (
f"Bold code in table should render as <code>. Got: {result}"
)
def test_multiple_code_spans_in_same_cell(self):
"""Multiple backtick spans in one cell all render as <code>."""
md = "| Combined |\n|---|\n| `a` and `b` |"
result = render_table(md)
assert result.count('<code>') == 2, (
f"Expected 2 code tags in cell, got: {result}"
)
def test_code_in_header_cell(self):
"""`code` in a <th> header cell must also render as <code>."""
md = "| `header code` | Normal |\n|---|---|\n| data | data |"
result = render_table(md)
assert '<code>header code</code>' in result, (
f"Code in header cell should render. Got: {result}"
)
def test_code_not_mangled_by_bold_in_table(self):
"""**`code`** in a table cell must NOT produce &lt;code&gt; (the pre-fix bug)."""
md = "| Pattern | Example |\n|---|---|\n| bold-code | **`npm install`** |"
result = render_table(md)
assert '&lt;code&gt;' not in result, (
f"Code tags inside bold in table must not be HTML-escaped. Got: {result}"
)
assert '<strong>' in result, "Bold wrapper should be present"
assert '<code>npm install</code>' in result
def test_code_with_special_chars_in_table(self):
"""`<script>` inside a table cell must have the angle brackets escaped."""
md = "| Input | Output |\n|---|---|\n| `<script>` | sanitized |"
result = render_table(md)
assert '&lt;script&gt;' in result, (
f"Code content must be HTML-escaped. Got: {result}"
)
# The <code> wrapper itself must be there
assert '<code>' in result
def test_code_adjacent_to_link_in_table(self):
"""`code` and [link](url) in same cell both render correctly."""
url = 'https://example.com'
md = f"| Mixed |\n|---|\n| `foo` and [bar]({url}) |"
result = render_table(md)
assert '<code>foo</code>' in result
assert f'href="{url}"' in result
assert 'bar' in result
def test_empty_code_span_in_table(self):
"""Edge case: empty backtick span in table cell (`` ` ` ``) — no crash."""
# This won't match the code regex (requires at least 1 char), should pass through
md = "| Col |\n|---|\n| normal text |"
result = render_table(md)
assert '<td>normal text</td>' in result
# ═════════════════════════════════════════════════════════════════════════════
# ISSUE #487 — JS renderer: markdown image syntax
# ═════════════════════════════════════════════════════════════════════════════
class TestIssue487ImageRendering:
"""Image syntax ![alt](url) must render as <img>, not as ! + link."""
# ── Source-level checks ──────────────────────────────────────────────────
def test_image_pass_present_in_ui_js(self):
"""renderMd() must contain an image regex pass for ![alt](url)."""
assert '![' in UI_JS or r'!\[' in UI_JS, (
"ui.js should contain image syntax handling (![...](url) regex)"
)
# More specifically, look for the img tag being generated
assert 'msg-media-img' in UI_JS, (
"Image pass should reuse .msg-media-img class"
)
def test_image_pass_runs_before_link_pass_in_outer(self):
"""Image regex must appear in ui.js BEFORE the [label](url) link pass."""
# Find the image pass position
img_idx = UI_JS.find('!\\[')
if img_idx == -1:
img_idx = UI_JS.find("![")
# Find the outer labeled link pass position (after table pass)
link_idx = UI_JS.find("Outer link pass for labeled links")
assert img_idx != -1, "Image pass not found in ui.js"
assert link_idx != -1, "Outer link pass comment not found in ui.js"
assert img_idx < link_idx, (
"Image pass must run before the outer [label](url) link pass "
"to prevent the image from being consumed as a plain link"
)
def test_image_url_sanitized_for_quotes(self):
"""Image src URL must have double-quotes percent-encoded."""
# The image pass must use .replace(/"/g,'%22') or equivalent
# Look for the pattern near image handling
img_idx = UI_JS.find('msg-media-img')
assert img_idx != -1
# Find all occurrences — there's the MEDIA restore and the new image pass
# The new one should have %22 for URL sanitization
assert '%22' in UI_JS, (
"Image src URL must sanitize double-quotes to %22"
)
def test_image_alt_uses_esc(self):
"""Alt text must be passed through esc() to prevent XSS."""
# Look for esc( call near the image rendering code
# The pattern should be: alt="${esc(alt)}"
assert 'esc(' in UI_JS, "esc() function must be used for alt text"
def test_safe_tags_includes_img(self):
"""SAFE_TAGS allowlist must include 'img' to prevent the tag from being escaped."""
# Find the SAFE_TAGS regex in ui.js
safe_idx = UI_JS.find('SAFE_TAGS=')
assert safe_idx != -1, "SAFE_TAGS not found in ui.js"
safe_window = UI_JS[safe_idx:safe_idx+300]
assert 'img' in safe_window, (
f"SAFE_TAGS must include 'img' tag. Found: {safe_window!r}"
)
def test_inlinemd_has_image_pass(self):
"""inlineMd() must also handle ![alt](url) for images inside table cells."""
# inlineMd is called for table cells, list items, blockquotes
# Find inlineMd function body
start = UI_JS.find('function inlineMd(')
assert start != -1, "inlineMd function not found"
# Get a generous window covering the function
fn_window = UI_JS[start:start+1500]
assert '![' in fn_window or r'!\[' in fn_window, (
"inlineMd() must handle image syntax for images in table cells"
)
# ── Behaviour tests (Python mirror) ─────────────────────────────────────
def test_basic_image_renders_as_img_tag(self):
"""![alt](https://example.com/img.png) must produce an <img> tag."""
t = '![A cat](https://example.com/cat.png)'
result = inline_md(t)
assert '<img ' in result, f"Expected <img> tag, got: {result}"
assert 'src="https://example.com/cat.png"' in result
assert 'alt="A cat"' in result
# Must NOT have the raw ![...] syntax left over
assert '![' not in result
# Must NOT have a stray ! character
assert result.startswith('<img '), f"Result should start with img tag: {result}"
def test_image_does_not_render_as_link(self):
"""![alt](url) must NOT produce an <a> tag (the pre-fix bug)."""
t = '![Logo](https://example.com/logo.png)'
result = inline_md(t)
assert '<a ' not in result, (
f"Image must not render as an <a> tag. Got: {result}"
)
def test_image_stray_exclamation_not_present(self):
"""No stray ! character before the img tag (the pre-fix symptom)."""
t = '![alt](https://example.com/img.png)'
result = inline_md(t)
# Strip the img tag and check no ! is left
cleaned = re.sub(r'<img[^>]+>', '', result)
assert '!' not in cleaned, (
f"Stray ! character present after image render. Got: {result}"
)
def test_image_uses_msg_media_img_class(self):
"""Rendered <img> must use class=\"msg-media-img\" for consistent styling."""
t = '![screenshot](https://example.com/shot.png)'
result = inline_md(t)
assert 'class="msg-media-img"' in result, (
f"Image must use .msg-media-img class. Got: {result}"
)
def test_image_has_lazy_loading(self):
"""Rendered <img> must have loading=\"lazy\"."""
t = '![x](https://example.com/x.png)'
result = inline_md(t)
assert 'loading="lazy"' in result, f"Expected loading=lazy. Got: {result}"
def test_image_has_click_to_zoom(self):
"""Rendered <img> must have onclick toggle for zoom."""
t = '![x](https://example.com/x.png)'
result = inline_md(t)
assert 'msg-media-img--full' in result, (
f"Image must have click-to-zoom onclick. Got: {result}"
)
def test_image_alt_is_escaped(self):
"""Alt text with HTML special chars must be escaped."""
t = '![<evil>](https://example.com/img.png)'
result = inline_md(t)
assert '&lt;evil&gt;' in result, (
f"Alt text must be HTML-escaped. Got: {result}"
)
assert '<evil>' not in result
def test_image_url_quote_sanitized(self):
"""Double-quote in image URL must be percent-encoded to prevent attribute breakout."""
t = '![x](https://example.com/path"with"quotes.png)'
result = inline_md(t)
# Find the src attribute value
src_match = re.search(r'src="([^"]*)"', result)
assert src_match, f"src attribute not found. Got: {result}"
src_val = src_match.group(1)
assert '"' not in src_val, (
f"Raw double-quote in src would break attribute. Got src: {src_val!r}"
)
def test_image_no_javascript_uri(self):
"""javascript: URIs must not be rendered as image src (regex only matches http/https)."""
t = '![x](javascript:alert(1))'
result = inline_md(t)
# The regex requires https?://, so this should pass through unmodified
assert '<img ' not in result, (
f"javascript: URI must not render as <img>. Got: {result}"
)
def test_image_no_data_uri(self):
"""data: URIs must not be rendered as image src."""
t = '![x](data:image/png;base64,abc123)'
result = inline_md(t)
assert '<img ' not in result, (
f"data: URI must not render as <img>. Got: {result}"
)
def test_image_followed_by_text(self):
"""Image followed by plain text — only the image becomes an <img>."""
t = '![cat](https://example.com/cat.png) and some text'
result = inline_md(t)
assert '<img ' in result
assert 'and some text' in result
def test_image_preceded_by_text(self):
"""Text before an image — both render correctly."""
t = 'Here is a screenshot: ![shot](https://example.com/shot.png)'
result = inline_md(t)
assert 'Here is a screenshot:' in result
assert '<img ' in result
def test_image_and_link_in_same_cell(self):
"""Image and link in same inline context both render correctly."""
t = '![img](https://example.com/img.png) see [here](https://example.com)'
result = inline_md(t)
assert '<img ' in result
assert '<a href="https://example.com"' in result
assert '![' not in result
def test_image_inside_table_cell(self):
"""![alt](url) inside a markdown table cell must render as <img>."""
md = ("| Image | Caption |\n"
"|---|---|\n"
"| ![logo](https://example.com/logo.png) | Company logo |")
result = render_table(md)
assert '<img ' in result, f"Image in table should render as <img>. Got: {result}"
assert 'src="https://example.com/logo.png"' in result
assert '<a ' not in result, "Image in table must not render as <a>"
def test_image_in_table_no_stray_exclamation(self):
"""No stray ! before the <img> when image is inside a table cell."""
md = ("| X |\n|---|\n| ![x](https://x.com/x.png) |")
result = render_table(md)
# Strip known tags and check no ! appears
cleaned = re.sub(r'<[^>]+>', '', result)
assert '!' not in cleaned, (
f"Stray ! in table cell after image render. Cleaned: {cleaned!r}"
)
def test_empty_alt_text_image(self):
"""![](url) with empty alt renders as <img> with empty alt attribute."""
t = '![](https://example.com/img.png)'
result = inline_md(t)
assert '<img ' in result
assert 'alt=""' in result
def test_multiple_images_in_one_cell(self):
"""Two images in one table cell both render as <img> tags."""
t = ('![a](https://example.com/a.png) '
'![b](https://example.com/b.png)')
result = inline_md(t)
assert result.count('<img ') == 2, (
f"Expected 2 img tags. Got: {result}"
)
def test_image_with_https_url(self):
"""https:// image URL renders correctly."""
t = '![secure](https://secure.example.com/img.jpg)'
result = inline_md(t)
assert 'src="https://secure.example.com/img.jpg"' in result
def test_image_with_http_url(self):
"""http:// image URL also renders (non-https still valid)."""
t = '![old](http://example.com/img.jpg)'
result = inline_md(t)
assert '<img ' in result
assert 'src="http://example.com/img.jpg"' in result
# ═════════════════════════════════════════════════════════════════════════════
# Cross-cutting: code + image together inside tables (the edge case Nathan flagged)
# ═════════════════════════════════════════════════════════════════════════════
class TestEdgeCasesCodeAndImageInTables:
"""Combination edge cases: code blocks and images mixed inside table cells."""
def test_code_and_image_in_same_table_row(self):
"""Table row with code in one cell and image in another renders both correctly."""
md = ("| Code | Preview |\n"
"|---|---|\n"
"| `print('hello')` | ![screenshot](https://example.com/shot.png) |")
result = render_table(md)
assert "<code>print(&#x27;hello&#x27;)</code>" in result or "<code>print('hello')</code>" in result, (
f"Code cell should render as <code>. Got: {result}"
)
assert '<img ' in result, "Image cell should render as <img>"
def test_code_in_cell_with_image_in_next_cell(self):
"""Multiple columns: code stays code, image stays image, no cross-contamination."""
md = ("| Step | Example |\n"
"|---|---|\n"
"| Run `npm install` | ![demo](https://example.com/demo.gif) |")
result = render_table(md)
assert '<code>npm install</code>' in result
assert '<img ' in result
assert '<a ' not in result # image must not become a link
def test_bold_code_in_cell_and_image_in_cell(self):
"""**`code`** in one cell and image in another — no esc() mangling."""
md = ("| Command | Result |\n"
"|---|---|\n"
"| **`git status`** | ![result](https://example.com/r.png) |")
result = render_table(md)
assert '&lt;code&gt;' not in result, (
"Bold+code in table cell must not produce escaped code tags"
)
assert '<code>git status</code>' in result
assert '<img ' in result
def test_link_code_image_all_in_table(self):
"""Table with code, link, and image cells all render correctly."""
url = 'https://github.com/issues/486'
img_url = 'https://example.com/img.png'
md = (f"| Code | Link | Image |\n"
f"|---|---|---|\n"
f"| `var x = 1` | [#486]({url}) | ![img]({img_url}) |")
result = render_table(md)
assert '<code>var x = 1</code>' in result
assert f'href="{url}"' in result
assert '<img ' in result
# No double-linking
assert result.count('<a ') == 1
def test_image_url_with_query_string_in_table(self):
"""Image URL with & in query string inside table cell — & not mangled."""
url = 'https://example.com/img?w=100&h=200'
md = f"| Image |\n|---|\n| ![sized]({url}) |"
result = render_table(md)
assert f'src="{url}"' in result, (
f"& in image URL must not be escaped. Got: {result}"
)
def test_image_adjacent_to_code_no_interference(self):
"""Image immediately followed by code span in same cell — no token cross-talk."""
t = '![x](https://x.com/x.png) `code`'
result = inline_md(t)
assert '<img ' in result
assert '<code>code</code>' in result
def test_image_inside_code_span_not_rendered(self):
"""An image syntax inside a backtick span must NOT render as an img tag."""
t = '`![not an image](https://example.com/img.png)`'
result = inline_md(t)
# The whole thing is inside backticks — should be literal code, not an img
assert '<img ' not in result, (
f"Image syntax inside code span must not render as <img>. Got: {result}"
)
# Should render as a code element with the raw text inside
assert '<code>' in result

131
tests/test_issue487b.py Normal file
View File

@@ -0,0 +1,131 @@
r"""
Regression test for image src URL corruption by the autolink pass.
Bug: the _al_stash before the autolink pass only stashed <a> tags.
<img> tags produced by the ![alt](url) image pass were NOT stashed,
so the autolink regex matched the URL inside src="..." and wrapped it
in <a href="...">url</a>, producing src="<a href="...">url</a>"
a completely broken image source.
Fix: extend _al_stash regex to also stash <img> tags:
(<a\b[^>]*>[\s\S]*?<\/a>|<img\b[^>]*>)
"""
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text()
# ── Source-level check ────────────────────────────────────────────────────────
def test_al_stash_includes_img_tags():
"""_al_stash regex must stash both <a> and <img> tags to protect src= from autolink."""
assert '<img\\b[^>]*>' in UI_JS or '<img\\\\b[^>]*>' in UI_JS, (
"_al_stash should include <img> tag pattern to prevent autolink mangling src= URLs"
)
# ── Behaviour tests (Python mirror of fixed pipeline) ─────────────────────────
import html as _html
def esc(s): return _html.escape(str(s), quote=True)
SAFE_TAGS = re.compile(
r'^</?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td'
r'|hr|blockquote|p|br|a|img|div|span)([\s>]|$)', re.I
)
def render_with_image_and_autolink(raw):
"""Simulate the image pass + SAFE_TAGS + _al_stash + autolink pipeline."""
s = raw
# Image pass
s = re.sub(
r'!\[([^\]]*)\]\((https?://[^\)]+)\)',
lambda m: (
f'<img src="{m.group(2).replace(chr(34), "%22")}" '
f'alt="{esc(m.group(1))}" class="msg-media-img" loading="lazy">'
),
s,
)
# SAFE_TAGS
s = re.sub(
r'</?[a-zA-Z][^>]*>',
lambda m: m.group() if SAFE_TAGS.match(m.group()) else esc(m.group()),
s,
)
# _al_stash (fixed: stashes both <a> and <img>)
al_stash = []
s = re.sub(
r'(<a\b[^>]*>[\s\S]*?<\/a>|<img\b[^>]*>)',
lambda m: (al_stash.append(m.group(1)) or f'\x00B{len(al_stash)-1}\x00'),
s,
)
# Autolink
def autolink(m):
url = m.group(1)
trail = url[-1] if url[-1] in '.,;:!?)' else ''
clean = url[:-1] if trail else url
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
s = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, s)
# Restore
s = re.sub(r'\x00B(\d+)\x00', lambda m: al_stash[int(m.group(1))], s)
return s
def test_image_src_not_mangled_by_autolink():
"""The URL inside src= of a rendered <img> must not be wrapped in <a> by autolink."""
url = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png'
result = render_with_image_and_autolink(f'![alt]({url})')
assert f'src="{url}"' in result, f"src= URL should be intact, got: {result[:200]}"
# The URL inside src= must NOT be wrapped in <a>
src_part = result.split('src="')[1].split('"')[0]
assert '<a ' not in src_part, f"src= must not contain <a> tag, got: {src_part}"
assert src_part == url, f"src= URL mangled: expected {url}, got {src_part}"
def test_image_tag_renders_as_img():
"""![alt](url) must produce an <img> tag, not a plain link."""
result = render_with_image_and_autolink('![Test image](https://example.com/img.png)')
assert '<img ' in result, f"Expected <img> tag, got: {result}"
assert 'src="https://example.com/img.png"' in result
assert '<a ' not in result # no spurious link wrapper
def test_image_and_link_in_same_paragraph():
"""Image and link in same paragraph must each render correctly without interference."""
result = render_with_image_and_autolink(
'See ![logo](https://example.com/logo.png) and visit https://example.com'
)
assert '<img ' in result, "Image should render"
assert '<a ' in result, "Bare URL should autolink"
# img src must not contain <a>
src_part = result.split('src="')[1].split('"')[0]
assert '<a' not in src_part, f"src= mangled: {src_part}"
def test_image_count_is_one():
"""One ![alt](url) should produce exactly one <img> tag."""
result = render_with_image_and_autolink('![test](https://example.com/x.png)')
assert result.count('<img ') == 1, f"Expected 1 <img>, got {result.count('<img ')}: {result}"
def test_multiple_images_not_mangled():
"""Multiple images in one message each get clean src= values."""
urls = [
'https://example.com/a.png',
'https://example.com/b.png',
]
raw = '\n\n'.join(f'![img{i}]({url})' for i, url in enumerate(urls))
result = render_with_image_and_autolink(raw)
for url in urls:
assert f'src="{url}"' in result, f"src= for {url} mangled in: {result[:300]}"
def test_image_with_query_string_src_intact():
"""Image URL with & in query string must have & (not &amp;) in src."""
url = 'https://example.com/img?w=100&h=200&fmt=png'
result = render_with_image_and_autolink(f'![img]({url})')
assert f'src="{url}"' in result, f"Query string URL mangled: {result[:200]}"
assert '&amp;' not in result.split('src="')[1].split('"')[0]

163
tests/test_issue569_579.py Normal file
View File

@@ -0,0 +1,163 @@
"""
Tests for fixes:
- #569: docker_init.bash auto-detects WANTED_UID/WANTED_GID from mounted workspace
so macOS users (UID 501) don't need to manually set the env var.
- #579: Topbar message count already filters tool messages (role !== 'tool').
The legacy raw sidebar count was removed by #584, and later reintroduced
in a gated detailed-density mode by #673.
"""
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent
INIT_SH = (REPO_ROOT / "docker_init.bash").read_text(encoding="utf-8")
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
# ── #569: docker UID/GID auto-detect ─────────────────────────────────────────
def test_569_uid_autodetect_present():
"""docker_init.bash must have workspace-based UID auto-detection (#569)."""
assert "stat -c '%u'" in INIT_SH or 'stat -c \'%u\'' in INIT_SH, (
"docker_init.bash must use stat to read workspace UID (#569)"
)
def test_569_gid_autodetect_present():
"""docker_init.bash must have workspace-based GID auto-detection (#569)."""
assert "stat -c '%g'" in INIT_SH or 'stat -c \'%g\'' in INIT_SH, (
"docker_init.bash must use stat to read workspace GID (#569)"
)
def test_569_autodetect_before_usermod():
"""UID auto-detect must appear before usermod call in docker_init.bash."""
detect_pos = INIT_SH.find("stat -c '%u'")
if detect_pos == -1:
detect_pos = INIT_SH.find("stat -c")
usermod_pos = INIT_SH.find("sudo usermod")
assert detect_pos != -1, "stat UID detection not found"
assert usermod_pos != -1, "sudo usermod not found"
assert detect_pos < usermod_pos, (
"UID auto-detect must occur before 'sudo usermod' so the correct UID "
"is used when remapping the hermeswebui user"
)
def test_569_skips_root_uid():
"""Auto-detect must not use UID 0 (root-owned mount = untrustworthy)."""
detect_block_start = INIT_SH.find("Auto-detect from mounted volumes")
assert detect_block_start != -1, "auto-detect comment block not found"
block = INIT_SH[detect_block_start:detect_block_start + 1200]
assert '"0"' in block or "'0'" in block, (
"Auto-detect block must skip UID 0 to avoid incorrectly using root ownership"
)
def test_569_fallback_preserved():
"""Hardcoded default 1024 fallback must still exist after auto-detect."""
assert "WANTED_UID=${WANTED_UID:-1024}" in INIT_SH, (
"WANTED_UID default fallback must remain so explicit env var still works"
)
assert "WANTED_GID=${WANTED_GID:-1024}" in INIT_SH, (
"WANTED_GID default fallback must remain"
)
# ── #668: UID/GID auto-detect from hermes-home shared volume (two-container) ──
def test_668_uid_autodetect_checks_hermes_home():
"""docker_init.bash must probe hermes-home dirs for UID in two-container setups.
When hermes-agent and hermes-webui run in separate containers sharing a
named volume, /workspace may not exist but ~/.hermes will be owned by the
agent's UID. The init script must probe it so the webui user is remapped
to match (#668).
"""
assert "/home/hermeswebui/.hermes" in INIT_SH, (
"docker_init.bash must probe /home/hermeswebui/.hermes for UID detection "
"to support two-container setups where /workspace may not exist (#668)"
)
def test_668_gid_autodetect_checks_hermes_home():
"""docker_init.bash must probe hermes-home dirs for GID in two-container setups (#668)."""
# Both UID and GID detection share the same probe dirs — check GID block too
gid_detect_start = INIT_SH.find("Auto-detect GID from mounted volumes")
assert gid_detect_start != -1, (
"GID auto-detect comment must be updated to mention shared volumes (#668)"
)
gid_block = INIT_SH[gid_detect_start:gid_detect_start + 600]
assert "/home/hermeswebui/.hermes" in gid_block or "HERMES_HOME" in gid_block, (
"GID auto-detect block must probe hermes-home dirs (#668)"
)
def test_668_uid_probe_loop_uses_break():
"""UID probe loop must stop on first match (no double-detection)."""
uid_detect_start = INIT_SH.find("Auto-detect from mounted volumes")
assert uid_detect_start != -1, "UID auto-detect comment not found"
uid_block = INIT_SH[uid_detect_start:uid_detect_start + 1200]
assert "break" in uid_block, (
"UID probe loop must break after first successful detection "
"to avoid being overridden by a later probe dir (#668)"
)
def test_668_hermes_home_probe_before_workspace():
"""Hermes-home probe must appear before /workspace probe in docker_init.bash (#668)."""
hermes_home_pos = INIT_SH.find("/home/hermeswebui/.hermes")
workspace_pos = INIT_SH.find('if [ -d "/workspace" ]')
assert hermes_home_pos != -1, "/home/hermeswebui/.hermes probe not found"
assert workspace_pos != -1, "/workspace probe not found"
assert hermes_home_pos < workspace_pos, (
"Hermes-home probe must come before /workspace probe — "
"shared volume UID should take priority over workspace UID (#668)"
)
# ── #579: topbar message count already filters tool messages ──────────────────
def test_579_topbar_filters_tool_messages():
"""ui.js topbar count must filter out role='tool' messages (#579).
The sidebar previously showed raw message_count (which included tool
messages), causing a mismatch with the topbar. PR #584 removed the
sidebar count display entirely; the topbar was already correct.
This test locks in the existing topbar filter so it can't regress.
"""
# Find the topbarMeta assignment
meta_pos = UI_JS.find("topbarMeta")
assert meta_pos != -1, "topbarMeta assignment not found in ui.js"
# Find the filter that precedes it — should exclude role==='tool'
context = UI_JS[max(0, meta_pos - 400):meta_pos + 100]
assert "role" in context and "tool" in context, (
"topbarMeta count must filter by role — "
"messages with role='tool' must be excluded from the displayed count"
)
# The filter must exclude tool messages (not include them)
assert "!=='tool'" in context or "!= 'tool'" in context or "role!=='tool'" in context, (
"topbar count filter must use !== 'tool' to exclude tool messages"
)
def test_579_sidebar_count_is_gated_behind_detailed_density():
"""sessions.js may only show sidebar count inside detailed density mode.
PR #584 removed the always-visible raw sidebar count to avoid mismatching the
topbar's filtered count. PR #673 later reintroduced message_count as
optional metadata, but only when the user explicitly opts into detailed
sidebar density.
"""
sessions_js = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
assert "const density=(window._sidebarDensity==='detailed'?'detailed':'compact');" in sessions_js, (
"sessions.js must normalize sidebar density before rendering metadata"
)
assert "if(density==='detailed'){" in sessions_js, (
"sessions.js must gate sidebar metadata behind detailed density mode"
)
assert "typeof s.message_count==='number'?s.message_count:0" in sessions_js, (
"message_count may be rendered only inside the detailed-density branch"
)

Some files were not shown because too many files have changed in this diff Show More