Skip to content
Merged
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@
---


## [v0.47.0] — 2026-04-11

### Features
- **`/skills [query]` slash command** (PR #257): Fetches from `/api/skills`, groups results by category (alphabetically), renders as a formatted assistant message. Optional query filters by name, description, or category. Shows in the `/` autocomplete dropdown. i18n for en/de/zh/zh-Hant. 1 regression test added.
- **Shared app dialogs replace native `confirm()`/`prompt()`** (PR #251, extracted from #242 by @aronprins): `showConfirmDialog()` and `showPromptDialog()` in `ui.js`, backed by `#appDialogOverlay`. Replaces all 11 native browser dialog call sites across panels.js, sessions.js, ui.js, workspace.js. Full keyboard focus trap (Tab/Escape/Enter), ARIA roles, danger mode, focus restore, mobile-responsive buttons. i18n for en/de/zh/zh-Hant. 5 new tests in `test_sprint33.py`.
- **Session `⋯` action dropdown** (PR #252, extracted from #242 by @aronprins): Replaces 5 per-row hover buttons (pin/move/archive/duplicate/delete) with a single `⋯` trigger. Menu uses `position:fixed` to avoid sidebar clipping. Full close handling: click-outside, scroll, Escape, resize-reposition. `test_sprint16.py` updated to assert the new trigger exists and old button classes are gone.

### Bug Fixes
- **Custom provider with slash model name no longer rerouted to OpenRouter** (PR #255): `resolve_model_provider()` now returns immediately with the configured `provider`/`base_url` when `base_url` is set, before the slash-based OpenRouter heuristic runs. Fixes `google/gemma-4-26b-a4b` with `provider: custom` being silently routed to OpenRouter (401 errors). 1 regression test added. Fixes #230.
- **Android Chrome: workspace panel now closeable on mobile** (PR #256): `toggleMobileFiles()` now shows/hides the mobile overlay. New `closeMobileFiles()` helper closes the right panel with correct overlay tracking. Overlay tap-to-close calls both `closeMobileSidebar()` and `closeMobileFiles()`. Mobile-only `×` close button added to workspace panel header. Fix applied during review: `closeMobileSidebar()` now checks if the right panel is still open before hiding the overlay. Fixes #247.
- **Android Chrome: profile dropdown no longer clipped on mobile** (PR #256): `.profile-dropdown` switches to `position:fixed; top:56px; right:8px` at `max-width:900px`, escaping the `overflow-x:auto` stacking context that was making it invisible. Fixes #246.

### Tests
- **Mobile layout regression suite** (PR #254): 14 static tests in `tests/test_mobile_layout.py` that run on every QA pass. Covers: CSS breakpoints at 900px/640px, right panel slide-over, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close, `100dvh`, 44px touch targets, 16px textarea font. All pass against current and future master.

**645 tests (up from 624 on v0.46.0 — +21 new tests)**

---

## [v0.46.0] — 2026-04-11

### Features
Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> 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.46.0 (April 11, 2026) — 624 tests, 624 passing
> Last updated: v0.47.0 (April 11, 2026) — 645 tests, 645 passing
> Tests: 604 total (604 passing, 0 failures)
> Source: <repo>/

Expand Down Expand Up @@ -43,6 +43,7 @@
| v0.44.0 patch | Fix batch: approval card, login CSP, update diagnostics, Lucide icons | PRs #221 #225 #226 #227 #228 | 579 |
| v0.45.0 | Custom endpoint in new profile form | Base URL + API key fields; server-side URL validation; config.yaml merge; 9 new tests (PR #233, fixes #170) | 604 |
| v0.46.0 | Security, Docker UID/GID, model discovery, i18n, cancel fix | Credential redaction in API responses (PR #243); Docker UID/GID matching (PR #237); custom model API key discovery (PR #238); HTML entity decode + zh/zh-Hant i18n (PR #239); cancel interrupts agent (PR #244); +20 tests | 624 |
| v0.47.0 | Dialogs, session menu, skills command, mobile fixes, mobile QA | Shared app dialogs (#251); session ⋯ menu (#252); mobile QA suite (#254); custom provider slash routing fix (#255); Android Chrome mobile fixes (#256); /skills command (#257); +21 tests | 645 |
| 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 |
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |
Expand Down
6 changes: 3 additions & 3 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
>
> Automated tests: 624 total (624 passing, 0 skipped, 0 known failures)
> Automated tests: 645 total (645 passing, 0 skipped, 0 known failures)
> Run: `pytest tests/ -v --timeout=60`

---
Expand Down Expand Up @@ -1708,8 +1708,8 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.

---

*Last updated: v0.46.0, April 11, 2026*
*Total automated tests: 624 (624 passing, 0 failures)*
*Last updated: v0.47.0, April 11, 2026*
*Total automated tests: 645 (645 passing, 0 failures)*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*
5 changes: 5 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,11 @@ def resolve_model_provider(model_id: str) -> tuple:
# e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
if config_provider and prefix == config_provider:
return bare, config_provider, config_base_url
# If a custom endpoint base_url is configured, don't reroute through OpenRouter
# just because the model name contains a slash (e.g. google/gemma-4-26b-a4b).
# The user has explicitly pointed at a base_url, so trust their routing config.
if config_base_url:
return model_id, config_provider, config_base_url
# If prefix does NOT match config provider, the user picked a cross-provider model
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
# In this case always route through openrouter with the full provider/model string.
Expand Down
29 changes: 27 additions & 2 deletions static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,37 @@ function closeMobileSidebar(){
const sidebar=document.querySelector('.sidebar');
const overlay=$('mobileOverlay');
if(sidebar)sidebar.classList.remove('mobile-open');
if(overlay)overlay.classList.remove('visible');
// only hide overlay if right panel is also closed
const panel=document.querySelector('.rightpanel');
if(!panel||!panel.classList.contains('mobile-open')){
if(overlay)overlay.classList.remove('visible');
}
}
function toggleMobileFiles(){
const panel=document.querySelector('.rightpanel');
const overlay=$('mobileOverlay');
if(!panel)return;
panel.classList.toggle('mobile-open');
if(panel.classList.contains('mobile-open')){
panel.classList.remove('mobile-open');
// only hide overlay if left sidebar is also closed
const sidebar=document.querySelector('.sidebar');
if(!sidebar||!sidebar.classList.contains('mobile-open')){
if(overlay)overlay.classList.remove('visible');
}
} else {
panel.classList.add('mobile-open');
if(overlay)overlay.classList.add('visible');
}
}
function closeMobileFiles(){
const panel=document.querySelector('.rightpanel');
const overlay=$('mobileOverlay');
if(panel)panel.classList.remove('mobile-open');
// only hide overlay if left sidebar is also closed
const sidebar=document.querySelector('.sidebar');
if(!sidebar||!sidebar.classList.contains('mobile-open')){
if(overlay)overlay.classList.remove('visible');
}
}
function mobileSwitchPanel(name){
// Switch the panel content view
Expand Down
44 changes: 44 additions & 0 deletions static/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const COMMANDS=[
{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'},
];

function parseCommand(text){
Expand Down Expand Up @@ -140,6 +141,49 @@ async function cmdTheme(args){
showToast(t('theme_set')+themeName);
}

async function cmdSkills(args){
try{
const data = await api('/api/skills');
let skills = data.skills || [];
if(args){
const q = args.toLowerCase();
skills = skills.filter(s =>
(s.name||'').toLowerCase().includes(q) ||
(s.description||'').toLowerCase().includes(q) ||
(s.category||'').toLowerCase().includes(q)
);
}
if(!skills.length){
const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'};
S.messages.push(msg); renderMessages(); return;
}
// Group by category
const byCategory = {};
skills.forEach(s => {
const cat = s.category || 'General';
if(!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(s);
});
const lines = [];
for(const [cat, items] of Object.entries(byCategory).sort()){
lines.push(`**${cat}**`);
items.forEach(s => {
const desc = s.description ? ` — ${s.description.slice(0,80)}${s.description.length>80?'...':''}` : '';
lines.push(` \`${s.name}\`${desc}`);
});
lines.push('');
}
const header = args
? `Skills matching "${args}" (${skills.length}):\n\n`
: `Available skills (${skills.length}):\n\n`;
S.messages.push({role:'assistant', content: header + lines.join('\n')});
renderMessages();
showToast(t('type_slash'));
}catch(e){
showToast('Failed to load skills: '+e.message);
}
}

async function cmdPersonality(args){
if(!S.session){showToast(t('no_active_session'));return;}
if(!args){
Expand Down
36 changes: 36 additions & 0 deletions static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const LOCALES = {
cmd_usage: 'Toggle token usage display on/off',
cmd_theme: 'Switch theme (dark/light/slate/solarized/monokai/nord/oled)',
cmd_personality: 'Switch agent personality',
cmd_skills: 'List available Hermes skills',
available_commands: 'Available commands:',
type_slash: 'Type / to see commands',
conversation_cleared: 'Conversation cleared',
Expand Down Expand Up @@ -136,6 +137,14 @@ const LOCALES = {
login_btn: 'Sign in',
login_invalid_pw: 'Invalid password',
login_conn_failed: 'Connection failed',
dialog_confirm_title: 'Confirm action',
dialog_prompt_title: 'Enter a value',
dialog_confirm_btn: 'Confirm',
discard: 'Discard',
clear: 'Clear',
create: 'Create',
remove: 'Remove',
project_name_prompt: 'Project name:',
// Sidebar & Tabs
tab_chat: 'Chat',
tab_tasks: 'Tasks',
Expand Down Expand Up @@ -238,6 +247,7 @@ const LOCALES = {
cmd_usage: 'Token-Verbrauchsanzeige umschalten',
cmd_theme: 'Theme wechseln (dark/light/slate/solarized/monokai/nord/oled)',
cmd_personality: 'Agenten-Persönlichkeit wechseln',
cmd_skills: 'Verfügbare Hermes-Skills auflisten',
available_commands: 'Verfügbare Befehle:',
type_slash: 'Tippe / für Befehle',
conversation_cleared: 'Konversation gelöscht',
Expand Down Expand Up @@ -316,6 +326,14 @@ const LOCALES = {
login_btn: 'Anmelden',
login_invalid_pw: 'Ungültiges Passwort',
login_conn_failed: 'Verbindung fehlgeschlagen',
dialog_confirm_title: 'Aktion bestätigen',
dialog_prompt_title: 'Wert eingeben',
dialog_confirm_btn: 'Bestätigen',
discard: 'Verwerfen',
clear: 'Leeren',
create: 'Erstellen',
remove: 'Entfernen',
project_name_prompt: 'Projektname:',
// Sidebar & Tabs
tab_chat: 'Chat',
tab_tasks: 'Aufgaben',
Expand Down Expand Up @@ -418,6 +436,7 @@ const LOCALES = {
cmd_usage: '\u5207\u6362 token \u7528\u91cf\u663e\u793a',
cmd_theme: '\u5207\u6362\u4e3b\u9898\uff08dark/light/slate/solarized/monokai/nord/oled\uff09',
cmd_personality: '\u5207\u6362 Agent \u4eba\u8bbe',
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
type_slash: '\u8f93\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
conversation_cleared: '\u5bf9\u8bdd\u5df2\u6e05\u7a7a',
Expand Down Expand Up @@ -496,6 +515,14 @@ const LOCALES = {
login_btn: '\u767b\u5f55',
login_invalid_pw: '\u5bc6\u7801\u9519\u8bef',
login_conn_failed: '\u8fde\u63a5\u5931\u8d25',
dialog_confirm_title: '确认操作',
dialog_prompt_title: '输入内容',
dialog_confirm_btn: '确认',
discard: '放弃',
clear: '清空',
create: '创建',
remove: '移除',
project_name_prompt: '项目名称:',
// missing keys from English
tab_chat: '\u804a\u5929',
tab_memory: '\u8a18\u61b6',
Expand Down Expand Up @@ -596,6 +623,7 @@ const LOCALES = {
cmd_usage: '\u5207\u63db token \u7528\u91cf\u986f\u793a',
cmd_theme: '\u5207\u63db\u4e3b\u984c\uff08dark/light/slate/solarized/monokai/nord/oled\uff09',
cmd_personality: '\u5207\u63db Agent \u4eba\u8a2d',
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
type_slash: '\u8f38\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
conversation_cleared: '\u5c0d\u8a71\u5df2\u6e05\u7a7a',
Expand Down Expand Up @@ -675,6 +703,14 @@ const LOCALES = {
login_invalid_pw: '\u5bc6\u78bc\u932f\u8aa4',
login_conn_failed: '\u9023\u63a5\u5931\u6557',
// missing keys from English
dialog_confirm_title: '確認操作',
dialog_prompt_title: '輸入內容',
dialog_confirm_btn: '確認',
discard: '放棄',
clear: '清空',
create: '建立',
remove: '移除',
project_name_prompt: '專案名稱:',
tab_chat: '\u804a\u5929',
tab_memory: '\u8a18\u61b6',
tab_skills: '\u6280\u80fd',
Expand Down
21 changes: 19 additions & 2 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.46.0</div></div></div>
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.47.0</div></div></div>
<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>
Expand Down Expand Up @@ -328,6 +328,7 @@ <h2 data-i18n="empty_title">What can I help with?</h2>
<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="closeMobileFiles()" title="Close">×</button>
</div>
</div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
Expand Down Expand Up @@ -438,7 +439,7 @@ <h3 style="margin:0;font-size:16px" data-i18n="settings_title">Settings</h3>
</div>
</div>
</div>
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar()"></div>
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar();closeMobileFiles()"></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>
Expand Down Expand Up @@ -471,5 +472,21 @@ <h3 style="margin:0;font-size:16px" data-i18n="settings_title">Settings</h3>
<script src="/static/messages.js"></script>
<script src="/static/panels.js"></script>
<script src="/static/boot.js"></script>
<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">
<div class="app-dialog-title" id="appDialogTitle">Confirm action</div>
<button class="app-dialog-close" id="appDialogClose" type="button" aria-label="Close dialog">
<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="app-dialog-desc" id="appDialogDesc"></div>
<input class="app-dialog-input" id="appDialogInput" type="text" style="display:none">
<div class="app-dialog-actions">
<button class="app-dialog-btn" id="appDialogCancel" type="button" data-i18n="cancel">Cancel</button>
<button class="app-dialog-btn confirm" id="appDialogConfirm" type="button">Confirm</button>
</div>
</div>
</div>
</body>
</html>
Loading