Merge pull request #446 from nesquena/release/v0.50.38

release: v0.50.38 — mobile nav cleanup, Prism highlighting, zh-CN/zh-Hant i18n
This commit is contained in:
nesquena-hermes
2026-04-14 10:15:42 -07:00
committed by GitHub
18 changed files with 1305 additions and 255 deletions

View File

@@ -63,7 +63,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)

View File

@@ -1,5 +1,31 @@
# Hermes Web UI -- Changelog
## [v0.50.38] feat: mobile nav cleanup, Prism syntax highlighting, zh-CN/zh-Hant i18n
Three community contributions combined:
**PR #425 — Remove mobile bottom nav (@aronprins)**
The fixed iOS-style bottom navigation bar on phones has been removed. The sidebar drawer
tabs already handle all navigation — the bottom nav was redundant and consumed ~56px of
vertical chat space. `test_mobile_layout.py` updated with `test_mobile_bottom_nav_removed()`
and new sidebar nav coverage tests.
**PR #426 — Prism syntax highlighting with light + dark theme token colors (@GiggleSamurai)**
Fenced code blocks now emit `class="language-{lang}"` on `<code>` elements, enabling Prism's
autoloader to apply token-level syntax highlighting. Added 36-line `:root[data-theme="light"]`
token color overrides scoped to light theme only; dark/dim/monokai/nord themes unaffected.
Background guard uses `var(--code-bg) !important` to prevent Prism's dark background from
overriding theme variables. 2 new regression tests in `test_issue_code_syntax_highlight.py`.
**PR #428 — zh-CN/zh-Hant i18n hardening (@vansour)**
Pluggable `resolvePreferredLocale()` function with smart zh-CN/zh-SG/zh-TW/zh-HK variant
mapping. Full zh-Simplified and zh-Traditional locale blocks added to `i18n.js`. Login page
locale routing updated in `api/routes.py` (`_resolve_login_locale_key()` helper). Hardcoded
strings in `panels.js` cron UI extracted to i18n keys. 3 new test files:
`test_chinese_locale.py`, `test_language_precedence.py`, `test_login_locale.py`.
- Total tests: 1073 (was 1063)
## [v0.50.37] fix(onboarding): skip wizard when Hermes is already configured
Fixes #420 — existing Hermes users with a valid `config.yaml` were shown the first-run

View File

@@ -277,8 +277,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:**
@@ -451,10 +451,10 @@ across 53 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
---
@@ -542,7 +542,7 @@ A run of focused quality-of-life improvements: terminal tool approval prompts th
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 + Android Chrome fixes (PRs #253, #263, #265)
Added the Profiles tab to the mobile bottom navigation bar, making profile switching reachable on phones, plus a set of Android Chrome-specific fixes for the profile dropdown.
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.

View File

@@ -39,7 +39,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 |
@@ -50,7 +50,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 |
@@ -223,7 +223,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

@@ -8,7 +8,7 @@
> 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: 1063 total (1063 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard.
> Automated tests: 1073 total (1073 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard.
> Run: `pytest tests/ -v --timeout=60`
---
@@ -1715,12 +1715,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).

View File

@@ -228,6 +228,24 @@ _LOGIN_LOCALE = {
"invalid_pw": "Invalid password",
"conn_failed": "Connection failed",
},
"es": {
"lang": "es-ES",
"title": "Iniciar sesi\u00f3n",
"subtitle": "Introduce tu contrase\u00f1a para continuar",
"placeholder": "Contrase\u00f1a",
"btn": "Entrar",
"invalid_pw": "Contrase\u00f1a inv\u00e1lida",
"conn_failed": "Error de conexi\u00f3n",
},
"de": {
"lang": "de-DE",
"title": "Anmelden",
"subtitle": "Geben Sie Ihr Passwort ein, um fortzufahren",
"placeholder": "Passwort",
"btn": "Anmelden",
"invalid_pw": "Ung\u00fcltiges Passwort",
"conn_failed": "Verbindung fehlgeschlagen",
},
"zh": {
"lang": "zh-CN",
"title": "\u767b\u5f55",
@@ -237,8 +255,49 @@ _LOGIN_LOCALE = {
"invalid_pw": "\u5bc6\u7801\u9519\u8bef",
"conn_failed": "\u8fde\u63a5\u5931\u8d25",
},
"zh-Hant": {
"lang": "zh-TW",
"title": "\u767b\u5f55",
"subtitle": "\u8f38\u5165\u5bc6\u78bc\u7e7c\u7e8c\u4f7f\u7528",
"placeholder": "\u5bc6\u78bc",
"btn": "\u767b\u5f55",
"invalid_pw": "\u5bc6\u78bc\u932f\u8aa4",
"conn_failed": "\u9023\u63a5\u5931\u6557",
},
}
def _resolve_login_locale_key(raw_lang: str | None) -> str:
"""Resolve settings.language to a known _LOGIN_LOCALE key."""
if not raw_lang:
return "en"
lang = str(raw_lang).strip()
if not lang:
return "en"
if lang in _LOGIN_LOCALE:
return lang
normalized = lang.replace("_", "-")
lower = normalized.lower()
# Case-insensitive direct key match first.
for key in _LOGIN_LOCALE:
if key.lower() == lower:
return key
# Common Chinese aliases.
if lower == "zh" or lower.startswith("zh-cn") or lower.startswith("zh-sg") or lower.startswith("zh-hans"):
return "zh"
if lower.startswith("zh-tw") or lower.startswith("zh-hk") or lower.startswith("zh-mo") or lower.startswith("zh-hant"):
return "zh-Hant" if "zh-Hant" in _LOGIN_LOCALE else "zh"
# Fallback to base language subtag (e.g. en-US -> en).
base = lower.split("-", 1)[0]
for key in _LOGIN_LOCALE:
if key.lower() == base:
return key
return "en"
# ── Login page (self-contained, no external deps) ────────────────────────────
_LOGIN_PAGE_HTML = """<!doctype html>
<html lang="{{LANG}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -294,7 +353,9 @@ def handle_get(handler, parsed) -> bool:
_settings = load_settings()
_bn = _html.escape(_settings.get("bot_name") or "Hermes")
_lang = _settings.get("language", "en")
_login_strings = _LOGIN_LOCALE.get(_lang, _LOGIN_LOCALE["en"])
_login_strings = _LOGIN_LOCALE[
_resolve_login_locale_key(_lang)
]
_page = (
_LOGIN_PAGE_HTML.replace("{{BOT_NAME}}", _bn)
.replace("{{BOT_NAME_INITIAL}}", _bn[0].upper())

View File

@@ -155,11 +155,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 {
@@ -170,10 +166,6 @@ 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=()=>{
@@ -592,7 +584,45 @@ 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);document.body.classList.toggle('bubble-layout',!!s.bubble_layout);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};document.body.classList.remove('bubble-layout');}
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);
document.body.classList.toggle('bubble-layout',!!s.bubble_layout);
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._botName='Hermes';
_bootSettings={check_for_updates:false};
document.body.classList.remove('bubble-layout');
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';

View File

@@ -290,6 +290,120 @@ const LOCALES = {
onboarding_error_workspace_required: 'Workspace is required.',
onboarding_error_model_required: 'Model is required.',
onboarding_complete: 'Onboarding complete',
// panel/runtime i18n
error_prefix: 'Error: ',
not_available: 'N/A',
never: 'never',
add: 'Add',
add_failed: 'Add failed: ',
remove_failed: 'Remove failed: ',
switch_failed: 'Switch failed: ',
name_required: 'Name is required',
content_required: 'Content is required',
view: 'View',
dismiss: 'Dismiss',
disable: 'Disable',
cron_no_jobs: 'No scheduled jobs found.',
cron_status_off: 'off',
cron_status_paused: 'paused',
cron_status_error: 'error',
cron_status_active: 'active',
cron_next: 'Next',
cron_last: 'Last',
cron_run_now: 'Run now',
cron_pause: 'Pause',
cron_resume: 'Resume',
cron_job_name_placeholder: 'Job name',
cron_schedule_placeholder: 'Schedule',
cron_prompt_placeholder: 'Prompt',
cron_last_output: 'Last output',
cron_all_runs: 'All runs',
cron_hide_runs: 'Hide runs',
cron_no_runs_yet: '(no runs yet)',
cron_schedule_required_example: 'Schedule is required (e.g. "0 9 * * *" or "every 1h")',
cron_schedule_required: 'Schedule is required',
cron_prompt_required: 'Prompt is required',
cron_job_created: 'Job created',
cron_job_triggered: 'Job triggered',
cron_job_paused: 'Job paused',
cron_job_resumed: 'Job resumed',
cron_job_updated: 'Job updated',
cron_delete_confirm_title: 'Delete cron job',
cron_delete_confirm_message: 'This cannot be undone.',
cron_job_deleted: 'Job deleted',
cron_completion_status: (name, status) => `Cron "${name}" ${status}`,
status_failed: 'failed',
status_completed: 'completed',
todos_no_active: 'No active task list in this session.',
clear_conversation_title: 'Clear conversation',
clear_conversation_message: 'Clear all messages? This cannot be undone.',
clear_failed: 'Clear failed: ',
skills_no_match: 'No skills match.',
linked_files: 'Linked Files',
skill_load_failed: 'Could not load skill: ',
skill_file_load_failed: 'Could not load file: ',
skill_name_required: 'Skill name is required',
skill_updated: 'Skill updated',
skill_created: 'Skill created',
memory_notes_label: 'memory (notes)',
memory_saved: 'Memory saved',
my_notes: 'My Notes',
user_profile: 'User Profile',
no_notes_yet: 'No notes yet.',
no_profile_yet: 'No profile yet.',
workspace_choose_path: 'Choose workspace path',
workspace_choose_path_meta: 'Add a validated path and switch this conversation',
workspace_manage: 'Manage workspaces',
workspace_manage_meta: 'Open the Spaces panel',
workspace_use_title: 'Use in current session',
workspace_use: 'Use',
workspace_add_path_placeholder: 'Add workspace path (e.g. /home/user/my-project)',
workspace_paths_validated_hint: 'Paths are validated as existing directories before saving.',
workspace_added: 'Workspace added',
workspace_remove_confirm_title: 'Remove workspace',
workspace_remove_confirm_message: (path) => `Remove "${path}"?`,
workspace_removed: 'Workspace removed',
workspace_switch_prompt_title: 'Switch workspace',
workspace_switch_prompt_message: 'Enter an absolute workspace path to add and switch this conversation to.',
workspace_switch_prompt_confirm: 'Switch',
workspace_switch_prompt_placeholder: '/Users/you/project',
workspace_not_added: 'Workspace was not added',
workspace_already_saved: 'Workspace already saved — choose it from the list',
workspace_busy_switch: 'Cannot switch workspace while agent is running',
discard_file_edits_title: 'Discard file edits?',
discard_file_edits_message: 'Switching workspaces will discard unsaved file edits in the preview.',
workspace_switched_to: (name) => `Switched to ${name}`,
profiles_no_profiles: 'No profiles found.',
profile_api_keys_configured: 'API keys configured',
profile_gateway_running: 'Gateway running',
profile_gateway_stopped: 'Gateway stopped',
profile_active: 'ACTIVE',
profile_no_configuration: 'No configuration',
profile_skill_count: (count) => `${count} skill${count === 1 ? '' : 's'}`,
profile_use: 'Use',
profile_switch_title: 'Switch to this profile',
profile_delete_title: 'Delete this profile',
manage_profiles: 'Manage profiles',
profiles_load_failed: 'Failed to load profiles',
profiles_busy_switch: 'Cannot switch profiles while agent is running',
profile_switched_new_conversation: (name) => `Switched to profile: ${name} — new conversation started`,
profile_switched: (name) => `Switched to profile: ${name}`,
profile_name_rule: 'Lowercase letters, numbers, hyphens, underscores only',
profile_base_url_rule: 'Base URL must start with http:// or https://',
profile_created: (name) => `Profile created: ${name}`,
profile_delete_confirm_title: (name) => `Delete profile "${name}"?`,
profile_delete_confirm_message: 'This removes all config, skills, memory, and sessions for this profile.',
profile_deleted: (name) => `Profile deleted: ${name}`,
active_conversation_none: 'No active conversation selected.',
active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`,
settings_unsaved_changes: 'You have unsaved changes.',
sign_out_failed: 'Sign out failed: ',
disable_auth_confirm_title: 'Disable password protection',
disable_auth_confirm_message: 'Anyone will be able to access this instance.',
auth_disabled: 'Auth disabled — password protection removed',
disable_auth_failed: 'Failed to disable auth: ',
bg_error_single: (title) => `"${title}" has encountered an error`,
bg_error_multi: (count) => `${count} sessions have encountered an error`,
},
es: {
@@ -570,6 +684,120 @@ const LOCALES = {
onboarding_error_workspace_required: 'El espacio de trabajo es obligatorio.',
onboarding_error_model_required: 'El modelo es obligatorio.',
onboarding_complete: 'Onboarding completado',
// panel/runtime i18n
error_prefix: 'Error: ',
not_available: 'N/A',
never: 'never',
add: 'Add',
add_failed: 'Add failed: ',
remove_failed: 'Remove failed: ',
switch_failed: 'Switch failed: ',
name_required: 'Name is required',
content_required: 'Content is required',
view: 'View',
dismiss: 'Dismiss',
disable: 'Disable',
cron_no_jobs: 'No scheduled jobs found.',
cron_status_off: 'off',
cron_status_paused: 'paused',
cron_status_error: 'error',
cron_status_active: 'active',
cron_next: 'Next',
cron_last: 'Last',
cron_run_now: 'Run now',
cron_pause: 'Pause',
cron_resume: 'Resume',
cron_job_name_placeholder: 'Job name',
cron_schedule_placeholder: 'Schedule',
cron_prompt_placeholder: 'Prompt',
cron_last_output: 'Last output',
cron_all_runs: 'All runs',
cron_hide_runs: 'Hide runs',
cron_no_runs_yet: '(no runs yet)',
cron_schedule_required_example: 'Schedule is required (e.g. "0 9 * * *" or "every 1h")',
cron_schedule_required: 'Schedule is required',
cron_prompt_required: 'Prompt is required',
cron_job_created: 'Job created',
cron_job_triggered: 'Job triggered',
cron_job_paused: 'Job paused',
cron_job_resumed: 'Job resumed',
cron_job_updated: 'Job updated',
cron_delete_confirm_title: 'Delete cron job',
cron_delete_confirm_message: 'This cannot be undone.',
cron_job_deleted: 'Job deleted',
cron_completion_status: (name, status) => `Cron "${name}" ${status}`,
status_failed: 'failed',
status_completed: 'completed',
todos_no_active: 'No active task list in this session.',
clear_conversation_title: 'Clear conversation',
clear_conversation_message: 'Clear all messages? This cannot be undone.',
clear_failed: 'Clear failed: ',
skills_no_match: 'No skills match.',
linked_files: 'Linked Files',
skill_load_failed: 'Could not load skill: ',
skill_file_load_failed: 'Could not load file: ',
skill_name_required: 'Skill name is required',
skill_updated: 'Skill updated',
skill_created: 'Skill created',
memory_notes_label: 'memory (notes)',
memory_saved: 'Memory saved',
my_notes: 'My Notes',
user_profile: 'User Profile',
no_notes_yet: 'No notes yet.',
no_profile_yet: 'No profile yet.',
workspace_choose_path: 'Choose workspace path',
workspace_choose_path_meta: 'Add a validated path and switch this conversation',
workspace_manage: 'Manage workspaces',
workspace_manage_meta: 'Open the Spaces panel',
workspace_use_title: 'Use in current session',
workspace_use: 'Use',
workspace_add_path_placeholder: 'Add workspace path (e.g. /home/user/my-project)',
workspace_paths_validated_hint: 'Paths are validated as existing directories before saving.',
workspace_added: 'Workspace added',
workspace_remove_confirm_title: 'Remove workspace',
workspace_remove_confirm_message: (path) => `Remove "${path}"?`,
workspace_removed: 'Workspace removed',
workspace_switch_prompt_title: 'Switch workspace',
workspace_switch_prompt_message: 'Enter an absolute workspace path to add and switch this conversation to.',
workspace_switch_prompt_confirm: 'Switch',
workspace_switch_prompt_placeholder: '/Users/you/project',
workspace_not_added: 'Workspace was not added',
workspace_already_saved: 'Workspace already saved — choose it from the list',
workspace_busy_switch: 'Cannot switch workspace while agent is running',
discard_file_edits_title: 'Discard file edits?',
discard_file_edits_message: 'Switching workspaces will discard unsaved file edits in the preview.',
workspace_switched_to: (name) => `Switched to ${name}`,
profiles_no_profiles: 'No profiles found.',
profile_api_keys_configured: 'API keys configured',
profile_gateway_running: 'Gateway running',
profile_gateway_stopped: 'Gateway stopped',
profile_active: 'ACTIVE',
profile_no_configuration: 'No configuration',
profile_skill_count: (count) => `${count} habilidad${count === 1 ? '' : 'es'}`,
profile_use: 'Use',
profile_switch_title: 'Switch to this profile',
profile_delete_title: 'Eliminar este perfil',
manage_profiles: 'Manage profiles',
profiles_load_failed: 'Failed to load profiles',
profiles_busy_switch: 'Cannot switch profiles while agent is running',
profile_switched_new_conversation: (name) => `Switched to profile: ${name} — new conversation started`,
profile_switched: (name) => `Switched to profile: ${name}`,
profile_name_rule: 'Lowercase letters, numbers, hyphens, underscores only',
profile_base_url_rule: 'Base URL must start with http:// or https://',
profile_created: (name) => `Profile created: ${name}`,
profile_delete_confirm_title: (name) => `Delete profile "${name}"?`,
profile_delete_confirm_message: 'This removes all config, skills, memory, and sessions for this profile.',
profile_deleted: (name) => `Profile deleted: ${name}`,
active_conversation_none: 'No active conversation selected.',
active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`,
settings_unsaved_changes: 'You have unsaved changes.',
sign_out_failed: 'Sign out failed: ',
disable_auth_confirm_title: 'Disable password protection',
disable_auth_confirm_message: 'Anyone will be able to access this instance.',
auth_disabled: 'Auth disabled — password protection removed',
disable_auth_failed: 'Failed to disable auth: ',
bg_error_single: (title) => `"${title}" has encountered an error`,
bg_error_multi: (count) => `${count} sessions have encountered an error`,
},
de: {
@@ -898,6 +1126,7 @@ const LOCALES = {
settings_label_theme: '\u4e3b\u9898',
settings_label_language: '\u8bed\u8a00',
settings_label_token_usage: '\u663e\u793a token \u7528\u91cf',
settings_label_bubble_layout: '聊天气泡布局',
settings_label_cli_sessions: '\u663e\u793a CLI \u4f1a\u8bdd',
settings_label_sync_insights: '\u540c\u6b65\u5230 insights',
settings_label_check_updates: '\u68c0\u67e5\u66f4\u65b0',
@@ -921,8 +1150,20 @@ const LOCALES = {
tab_tasks: '任务',
tab_todos: '待办',
tab_workspaces: '工作区',
tab_profiles: '配置',
new_conversation: '新建对话',
filter_conversations: '筛选对话…',
session_time_unknown: '未知',
session_time_just_now: '刚刚',
session_time_minutes_ago: (n) => `${n} 分钟前`,
session_time_hours_ago: (n) => `${n} 小时前`,
session_time_days_ago: (n) => `${n} 天前`,
session_time_last_week: '上周',
session_time_bucket_today: '今天',
session_time_bucket_yesterday: '昨天',
session_time_bucket_this_week: '本周',
session_time_bucket_last_week: '上周',
session_time_bucket_older: '更早',
scheduled_jobs: '定时任务',
new_job: '新任务',
search_skills: '搜索技能…',
@@ -930,6 +1171,7 @@ const LOCALES = {
save_skill: '保存技能',
personal_memory: '个人记忆',
current_task_list: '当前任务列表',
workspace_desc: '为你的会话添加并切换工作区。',
new_profile: '新配置',
transcript: '记录',
download_transcript: '下载为 Markdown',
@@ -951,11 +1193,202 @@ const LOCALES = {
settings_desc_sound: '助手完成回复时播放提示音。',
settings_desc_notifications: '当标签页在后台时,回复完成后显示系统通知。',
settings_desc_token_usage: '在助手每次回复下方显示输入/输出 token 数量。也可以用 /usage 切换。',
settings_desc_bubble_layout: '开启后将用户消息右对齐、助手消息左对齐。默认关闭,以保持代码块和工具输出为全宽显示。',
settings_desc_cli_sessions: '将 Hermes CLIstate.db中的会话合并到会话列表。点击某个 CLI 会话可导入并继续对话。',
settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db使 hermes /insights 包含浏览器会话数据。默认关闭。',
settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。',
settings_desc_bot_name: '助手在 UI 中的显示名称。默认为 Hermes。',
settings_desc_password: '输入新密码以设置或更改。留空保持当前设置。',
// onboarding
onboarding_badge: '首次运行',
onboarding_title: '欢迎使用 Hermes Web UI',
onboarding_lead: '快速引导将验证 Hermes、保存真实的提供商配置、选择工作区和模型并可选设置密码保护应用。',
onboarding_back: '返回',
onboarding_continue: '继续',
onboarding_skip: '跳过设置',
onboarding_skipped: '设置已跳过 — 使用现有配置。',
onboarding_open: '打开 Hermes',
onboarding_step_system_title: '系统检查',
onboarding_step_system_desc: '验证 Hermes Agent 与配置可见性。',
onboarding_step_setup_title: '提供商设置',
onboarding_step_setup_desc: '保存最小可用的 Hermes 提供商配置。',
onboarding_step_workspace_title: '工作区 + 模型',
onboarding_step_workspace_desc: '为新会话和聊天选择默认值。',
onboarding_step_password_title: '可选密码',
onboarding_step_password_desc: '在分享前为 Web UI 添加保护。',
onboarding_step_finish_title: '完成',
onboarding_step_finish_desc: '确认信息并进入应用。',
onboarding_notice_system_ready: 'Hermes Agent 看起来可从 Web UI 访问。',
onboarding_notice_system_unavailable: 'Hermes Agent 尚未完全可用。Bootstrap 可以安装它,但提供商设置可能仍需要终端。',
onboarding_check_agent: 'Hermes Agent',
onboarding_check_agent_ready: '已检测且可导入',
onboarding_check_agent_missing: '缺失或仅部分可导入',
onboarding_check_password: '密码',
onboarding_check_password_enabled: '已启用',
onboarding_check_password_disabled: '尚未启用',
onboarding_check_provider: '提供商配置',
onboarding_check_provider_ready: '可开始聊天',
onboarding_check_provider_partial: '已保存但不完整',
onboarding_check_provider_pending: '需要验证',
onboarding_config_file: '配置文件:',
onboarding_env_file: '.env 文件:',
onboarding_unknown: '未知',
onboarding_current_provider: '当前配置:',
onboarding_missing_imports: '缺失导入:',
onboarding_notice_setup_required: '请先在此选择一个简单的提供商路径。高级 OAuth 流程暂时仍建议在 Hermes CLI 中完成。',
onboarding_notice_setup_already_ready: '已检测到可用的 Hermes 提供商配置。你可以保留它,或在这里替换。',
onboarding_oauth_provider_ready_title: '提供商已完成认证',
onboarding_oauth_provider_ready_body: '此实例已配置为使用通过 Hermes CLI 设置的 OAuth 提供商(<strong>{provider}</strong>)。这里不需要 API key点击继续即可完成设置。',
onboarding_oauth_provider_not_ready_title: 'OAuth 提供商尚未认证',
onboarding_oauth_provider_not_ready_body: '此实例已配置为使用 <strong>{provider}</strong>,该提供商使用 OAuth 而非 API key。请在终端运行 <code>hermes auth</code> 或 <code>hermes model</code> 完成认证,然后重新加载 Web UI。',
onboarding_oauth_switch_hint: '或者在下方选择其他提供商,切换到 API key 配置:',
onboarding_notice_workspace: '这些值复用与正式应用相同的设置 API。',
onboarding_workspace_label: '工作区',
onboarding_workspace_or_path: '或输入工作区路径',
onboarding_workspace_placeholder: '/home/you/workspace',
onboarding_provider_label: '设置模式',
onboarding_quick_setup_badge: '快速设置',
onboarding_api_key_label: 'API key',
onboarding_api_key_placeholder: '留空可保留已保存的 key',
onboarding_api_key_help_prefix: '会作为密钥保存到 Hermes .env 文件中,变量名为',
onboarding_base_url_label: 'Base URL',
onboarding_base_url_placeholder: 'https://your-endpoint.example/v1',
onboarding_base_url_help: '用于 OpenAI 兼容路由、自托管服务、LiteLLM、Ollama、LM Studio、vLLM 或类似端点。',
onboarding_model_label: '默认模型',
onboarding_workspace_help: '选择设置完成后 Hermes 在新聊天中使用的模型。',
onboarding_custom_model_placeholder: 'your-model-name',
onboarding_custom_model_help: '对于自定义端点,请填写服务端要求的精确模型 ID。',
onboarding_notice_password_enabled: '已配置密码。仅在你想替换时输入新密码。',
onboarding_notice_password_recommended: '可选,但如果你会把 UI 暴露到 localhost 之外,建议设置。',
onboarding_password_label: '密码(可选)',
onboarding_password_placeholder: '留空则跳过',
onboarding_password_help: '密码通过现有设置 API 保存,并在服务端进行哈希处理。',
onboarding_notice_finish: '你之后仍可在设置中修改这些选项。',
onboarding_not_set: '未设置',
onboarding_password_will_enable: '将启用',
onboarding_password_skipped: '暂时跳过',
onboarding_finish_help: '完成后会在设置中写入 <code>onboarding_completed</code>,并进入常规应用界面。',
onboarding_error_choose_workspace: '继续前请先选择工作区。',
onboarding_error_choose_model: '继续前请先选择模型。',
onboarding_error_provider_required: '继续前请先选择设置模式。',
onboarding_error_base_url_required: '自定义端点必须填写 Base URL。',
onboarding_error_workspace_required: '必须填写工作区。',
onboarding_error_model_required: '必须填写模型。',
onboarding_complete: '引导完成',
// panel/runtime i18n
error_prefix: '错误:',
not_available: '无',
never: '从未',
add: '添加',
add_failed: '添加失败:',
remove_failed: '移除失败:',
switch_failed: '切换失败:',
name_required: '名称不能为空',
content_required: '内容不能为空',
view: '查看',
dismiss: '忽略',
disable: '停用',
cron_no_jobs: '未找到定时任务。',
cron_status_off: '关闭',
cron_status_paused: '暂停',
cron_status_error: '错误',
cron_status_active: '运行中',
cron_next: '下次',
cron_last: '上次',
cron_run_now: '立即运行',
cron_pause: '暂停',
cron_resume: '恢复',
cron_job_name_placeholder: '任务名称',
cron_schedule_placeholder: '调度表达式',
cron_prompt_placeholder: '提示词',
cron_last_output: '最近输出',
cron_all_runs: '全部运行记录',
cron_hide_runs: '隐藏记录',
cron_no_runs_yet: '(暂无运行记录)',
cron_schedule_required_example: '必须填写调度(例如 "0 9 * * *" 或 "every 1h"',
cron_schedule_required: '必须填写调度',
cron_prompt_required: '必须填写提示词',
cron_job_created: '任务已创建',
cron_job_triggered: '任务已触发',
cron_job_paused: '任务已暂停',
cron_job_resumed: '任务已恢复',
cron_job_updated: '任务已更新',
cron_delete_confirm_title: '删除定时任务',
cron_delete_confirm_message: '此操作无法撤销。',
cron_job_deleted: '任务已删除',
cron_completion_status: (name, status) => `定时任务“${name}${status}`,
status_failed: '失败',
status_completed: '完成',
todos_no_active: '此会话暂无活动任务列表。',
clear_conversation_title: '清空对话',
clear_conversation_message: '要清空所有消息吗?此操作无法撤销。',
clear_failed: '清空失败:',
skills_no_match: '没有匹配的技能。',
linked_files: '关联文件',
skill_load_failed: '加载技能失败:',
skill_file_load_failed: '加载文件失败:',
skill_name_required: '技能名称不能为空',
skill_updated: '技能已更新',
skill_created: '技能已创建',
memory_notes_label: '记忆(备注)',
memory_saved: '记忆已保存',
my_notes: '我的备注',
user_profile: '用户画像',
no_notes_yet: '暂无备注。',
no_profile_yet: '暂无用户画像。',
workspace_choose_path: '选择工作区路径',
workspace_choose_path_meta: '添加已校验路径并切换当前会话',
workspace_manage: '管理工作区',
workspace_manage_meta: '打开 Spaces 面板',
workspace_use_title: '用于当前会话',
workspace_use: '使用',
workspace_add_path_placeholder: '添加工作区路径(例如 /home/user/my-project',
workspace_paths_validated_hint: '保存前会校验路径是否为已存在目录。',
workspace_added: '工作区已添加',
workspace_remove_confirm_title: '移除工作区',
workspace_remove_confirm_message: (path) => `要移除“${path}”吗?`,
workspace_removed: '工作区已移除',
workspace_switch_prompt_title: '切换工作区',
workspace_switch_prompt_message: '输入绝对路径以添加并切换当前会话的工作区。',
workspace_switch_prompt_confirm: '切换',
workspace_switch_prompt_placeholder: '/Users/you/project',
workspace_not_added: '工作区未添加成功',
workspace_already_saved: '工作区已存在,请在列表中选择',
workspace_busy_switch: 'Agent 运行中,无法切换工作区',
discard_file_edits_title: '放弃文件编辑?',
discard_file_edits_message: '切换工作区将丢弃预览区未保存的文件修改。',
workspace_switched_to: (name) => `已切换到 ${name}`,
profiles_no_profiles: '未找到配置档。',
profile_api_keys_configured: '已配置 API 密钥',
profile_gateway_running: '网关运行中',
profile_gateway_stopped: '网关已停止',
profile_active: '当前',
profile_no_configuration: '无配置',
profile_skill_count: (count) => `${count} 个技能`,
profile_use: '使用',
profile_switch_title: '切换到此配置档',
profile_delete_title: '删除此配置档',
manage_profiles: '管理配置档',
profiles_load_failed: '加载配置档失败',
profiles_busy_switch: 'Agent 运行中,无法切换配置档',
profile_switched_new_conversation: (name) => `已切换到配置档:${name},并新建对话`,
profile_switched: (name) => `已切换到配置档:${name}`,
profile_name_rule: '仅允许小写字母、数字、连字符和下划线',
profile_base_url_rule: 'Base URL 必须以 http:// 或 https:// 开头',
profile_created: (name) => `配置档已创建:${name}`,
profile_delete_confirm_title: (name) => `删除配置档“${name}”?`,
profile_delete_confirm_message: '这将删除该配置档的所有配置、技能、记忆和会话。',
profile_deleted: (name) => `配置档已删除:${name}`,
active_conversation_none: '当前未选择活动会话。',
active_conversation_meta: (title, count) => `${title} · ${count} 条消息`,
settings_unsaved_changes: '你有未保存的更改。',
sign_out_failed: '退出登录失败:',
disable_auth_confirm_title: '停用密码保护',
disable_auth_confirm_message: '任何人都可以访问此实例。',
auth_disabled: '认证已停用,密码保护已移除',
disable_auth_failed: '停用认证失败:',
bg_error_single: (title) => `${title}”出现错误`,
bg_error_multi: (count) => `${count} 个会话出现错误`,
},
// Traditional Chinese (zh-Hant)
@@ -1144,23 +1577,6 @@ const LOCALES = {
settings_desc_check_updates: '\u7576\u6709\u66f4\u65b0\u7684 WebUI \u6216\u52a9\u624b\u7248\u672c\u6642\u986f\u793a\u6a19\u8a18\u3002\u5c07\u5728\u5f8c\u81ea\u6b63\u5e38\u57f7\u884c Git-Fetch\u3002',
settings_desc_bot_name: '\u52a9\u624b\u5728 UI \u4e2d\u7684\u986f\u793a\u540d\u7a31\u3002\u9810\u8a2d\u70b8\u7528\u6539\u3002',
settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002',
settings_label_sound: '\u901a\u77e5\u8072\u97f3',
// boot.js
cancelling: '\u6b63\u5728\u53d6\u6d88...',
cancel_failed: '\u53d6\u6d88\u5931\u6557\uff1a',
mic_denied: '\u9ea6\u514b\u98a8\u8a2a\u554f\u88ab\u62d2\u7d75\uff0c\u8acb\u6aa2\u67e5\u700f\u89bd\u5668\u6b0a\u9650\u3002',
mic_no_speech: '\u6c92\u6709\u6aa2\u6e2c\u5230\u8a71\u97f3\uff0c\u8acb\u518d\u5617\u4e00\u6b21\u3002',
mic_network: '\u8a71\u97f3\u8b58\u5225\u76ee\u524d\u4e0d\u53ef\u7528\u3002',
mic_error: '\u8a71\u97f3\u8f38\u5165\u51fa\u932f\uff1a',
session_imported: '\u6703\u8a71\u5df2\u5c0e\u5165',
import_failed: '\u5c0e\u5165\u5931\u6557\uff1a',
import_invalid_json: 'JSON \u7121\u6548',
image_pasted: '\u5df2\u7c98\u8cbc\u5716\u7247\uff1a',
// messages.js
edit_message: '\u7de8\u8f2f\u8a0a\u606f',
regenerate: '\u91cd\u65b0\u751f\u6210\u56de\u8986',
copy: '\u8907\u88fd',
copied: '\u5df2\u8907\u88fd',
// ui.js
workspace_desc: '\u8acb\u9078\u64c7\u5de5\u4f5c\u5340\uff0c\u6216\u8f09\u5165\u65b0\u540d\u7a31\u5beb\u4e00\u500b',
tab_profiles: '\u914d\u7f6e',
@@ -1170,6 +1586,52 @@ const LOCALES = {
// Active locale — defaults to English; overridden by loadLocale() at boot.
let _locale = LOCALES.en;
/**
* Resolve an incoming locale tag to a known LOCALES key.
* Supports exact keys, case-insensitive matches, and a few common aliases
* (e.g. zh-CN -> zh, zh-TW -> zh-Hant). Returns null when unresolved.
* @param {string} lang
* @returns {string|null}
*/
function resolveLocale(lang) {
if (typeof lang !== 'string') return null;
const raw = lang.trim();
if (!raw) return null;
if (LOCALES[raw]) return raw;
const lower = raw.toLowerCase().replace(/_/g, '-');
// Case-insensitive direct match first.
const direct = Object.keys(LOCALES).find((k) => k.toLowerCase() === lower);
if (direct) return direct;
// Common Chinese variants.
if (lower === 'zh' || lower.startsWith('zh-cn') || lower.startsWith('zh-sg') || lower.startsWith('zh-hans')) {
return LOCALES.zh ? 'zh' : null;
}
if (lower.startsWith('zh-tw') || lower.startsWith('zh-hk') || lower.startsWith('zh-mo') || lower.startsWith('zh-hant')) {
return LOCALES['zh-Hant'] ? 'zh-Hant' : null;
}
// Fallback to base language subtag (e.g. en-US -> en).
const base = lower.split('-')[0];
const baseMatch = Object.keys(LOCALES).find((k) => k.toLowerCase() === base);
return baseMatch || null;
}
/**
* Resolve locale with precedence:
* 1) primary (typically server setting)
* 2) fallback (typically localStorage)
* 3) English
* @param {string} primary
* @param {string} fallback
* @returns {string}
*/
function resolvePreferredLocale(primary, fallback) {
return resolveLocale(primary) || resolveLocale(fallback) || 'en';
}
/**
* Translate a key. Falls back to English if the key is missing in the active locale.
* Supports function values (for interpolated strings): call t('key', arg).
@@ -1189,7 +1651,7 @@ function t(key, ...args) {
* @param {string} lang
*/
function setLocale(lang) {
const resolved = LOCALES[lang] ? lang : 'en';
const resolved = resolveLocale(lang) || 'en';
_locale = LOCALES[resolved];
localStorage.setItem('hermes-lang', resolved);
document.documentElement.lang = _locale._speech || resolved;
@@ -1200,8 +1662,7 @@ function setLocale(lang) {
* Server-persisted preference is applied later in loadSettingsPanel().
*/
function loadLocale() {
const saved = localStorage.getItem('hermes-lang');
setLocale(saved && LOCALES[saved] ? saved : 'en');
setLocale(resolvePreferredLocale(null, localStorage.getItem('hermes-lang')));
}
/**

View File

@@ -536,7 +536,7 @@
<div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div>
</div>
<span class="settings-version-badge">v0.50.37</span>
<span class="settings-version-badge">v0.50.38</span>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
@@ -553,32 +553,6 @@
</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">

View File

@@ -24,7 +24,7 @@ async function loadCrons() {
try {
const data = await api('/api/crons');
if (!data.jobs || !data.jobs.length) {
box.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:12px">No scheduled jobs found.</div>';
box.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('cron_no_jobs'))}</div>`;
return;
}
box.innerHTML = '';
@@ -33,42 +33,42 @@ async function loadCrons() {
item.className = 'cron-item';
item.id = 'cron-' + job.id;
const statusClass = job.enabled === false ? 'disabled' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active';
const statusLabel = job.enabled === false ? 'off' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active';
const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'N/A';
const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : 'never';
const statusLabel = job.enabled === false ? t('cron_status_off') : job.state === 'paused' ? t('cron_status_paused') : job.last_status === 'error' ? t('cron_status_error') : t('cron_status_active');
const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : t('not_available');
const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : t('never');
item.innerHTML = `
<div class="cron-header" onclick="toggleCron('${job.id}')">
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
<span class="cron-status ${statusClass}">${statusLabel}</span>
</div>
<div class="cron-body" id="cron-body-${job.id}">
<div class="cron-schedule">${li('clock',12)} ${esc(job.schedule_display || job.schedule?.expression || '')} &nbsp;|&nbsp; Next: ${esc(nextRun)} &nbsp;|&nbsp; Last: ${esc(lastRun)}</div>
<div class="cron-schedule">${li('clock',12)} ${esc(job.schedule_display || job.schedule?.expression || '')} &nbsp;|&nbsp; ${esc(t('cron_next'))}: ${esc(nextRun)} &nbsp;|&nbsp; ${esc(t('cron_last'))}: ${esc(lastRun)}</div>
<div class="cron-prompt">${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}</div>
<div class="cron-actions">
<button class="cron-btn run" onclick="cronRun('${job.id}')">${li('play',12)} Run now</button>
${statusLabel==='paused'
? `<button class="cron-btn" onclick="cronResume('${job.id}')">${li('play',12)} Resume</button>`
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">${li('pause',12)} Pause</button>`}
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'&quot;')})">${li('pencil',12)} Edit</button>
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">${li('trash-2',12)} Delete</button>
<button class="cron-btn run" onclick="cronRun('${job.id}')">${li('play',12)} ${esc(t('cron_run_now'))}</button>
${job.state==='paused'
? `<button class="cron-btn" onclick="cronResume('${job.id}')">${li('play',12)} ${esc(t('cron_resume'))}</button>`
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">${li('pause',12)} ${esc(t('cron_pause'))}</button>`}
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'&quot;')})">${li('pencil',12)} ${esc(t('edit'))}</button>
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">${li('trash-2',12)} ${esc(t('delete_title'))}</button>
</div>
<!-- Inline edit form, hidden by default -->
<div id="cron-edit-${job.id}" style="display:none;margin-top:8px;border-top:1px solid var(--border);padding-top:8px">
<input id="cron-edit-name-${job.id}" placeholder="Job name" 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:5px;box-sizing:border-box">
<input id="cron-edit-schedule-${job.id}" placeholder="Schedule" 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:5px;box-sizing:border-box">
<textarea id="cron-edit-prompt-${job.id}" rows="3" placeholder="Prompt" 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:5px;box-sizing:border-box"></textarea>
<input id="cron-edit-name-${job.id}" placeholder="${esc(t('cron_job_name_placeholder'))}" 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:5px;box-sizing:border-box">
<input id="cron-edit-schedule-${job.id}" placeholder="${esc(t('cron_schedule_placeholder'))}" 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:5px;box-sizing:border-box">
<textarea id="cron-edit-prompt-${job.id}" rows="3" placeholder="${esc(t('cron_prompt_placeholder'))}" 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:5px;box-sizing:border-box"></textarea>
<div id="cron-edit-err-${job.id}" style="font-size:11px;color:var(--accent);display:none;margin-bottom:5px"></div>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="cronEditSave('${job.id}')">Save</button>
<button class="cron-btn" style="flex:1" onclick="cronEditClose('${job.id}')">Cancel</button>
<button class="cron-btn run" style="flex:1" onclick="cronEditSave('${job.id}')">${esc(t('save'))}</button>
<button class="cron-btn" style="flex:1" onclick="cronEditClose('${job.id}')">${esc(t('cancel'))}</button>
</div>
</div>
<div id="cron-output-${job.id}">
<div class="cron-last-header" style="display:flex;align-items:center;justify-content:space-between">
<span>Last output</span>
<button class="cron-btn" style="padding:1px 8px;font-size:10px" onclick="loadCronHistory('${job.id}',this)">All runs</button>
<span>${esc(t('cron_last_output'))}</span>
<button class="cron-btn" style="padding:1px 8px;font-size:10px" onclick="loadCronHistory('${job.id}',this)">${esc(t('cron_all_runs'))}</button>
</div>
<div class="cron-last" id="cron-out-text-${job.id}" style="color:var(--muted);font-size:11px">Loading…</div>
<div class="cron-last" id="cron-out-text-${job.id}" style="color:var(--muted);font-size:11px">${esc(t('loading'))}</div>
<div id="cron-history-${job.id}" style="display:none"></div>
</div>
</div>`;
@@ -76,7 +76,7 @@ async function loadCrons() {
// Eagerly load last output for visible items
loadCronOutput(job.id);
}
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`; }
}
let _cronSelectedSkills=[];
@@ -164,18 +164,18 @@ async function submitCronCreate(){
const deliver=$('cronFormDeliver').value;
const errEl=$('cronFormError');
errEl.style.display='none';
if(!schedule){errEl.textContent='Schedule is required (e.g. "0 9 * * *" or "every 1h")';errEl.style.display='';return;}
if(!prompt){errEl.textContent='Prompt is required';errEl.style.display='';return;}
if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;}
if(!prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;}
try{
const body={schedule,prompt,deliver};
if(name)body.name=name;
if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills;
await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)});
toggleCronForm();
showToast('Job created');
showToast(t('cron_job_created'));
await loadCrons();
}catch(e){
errEl.textContent='Error: '+e.message;errEl.style.display='';
errEl.textContent=t('error_prefix')+e.message;errEl.style.display='';
}
}
@@ -192,7 +192,7 @@ async function loadCronOutput(jobId) {
const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=1`);
const el = $('cron-out-text-' + jobId);
if (!el) return;
if (!data.outputs || !data.outputs.length) { el.textContent = '(no runs yet)'; return; }
if (!data.outputs || !data.outputs.length) { el.textContent = t('cron_no_runs_yet'); return; }
const out = data.outputs[0];
const ts = out.filename.replace('.md','').replace(/_/g,' ');
el.textContent = ts + '\n\n' + _cronOutputSnippet(out.content);
@@ -205,14 +205,14 @@ async function loadCronHistory(jobId, btn) {
// Toggle: if already open, close it
if (histEl.style.display !== 'none') {
histEl.style.display = 'none';
if (btn) btn.textContent = 'All runs';
if (btn) btn.textContent = t('cron_all_runs');
return;
}
if (btn) btn.textContent = 'Loading';
if (btn) btn.textContent = t('loading');
try {
const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`);
if (!data.outputs || !data.outputs.length) {
histEl.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:4px 0">(no runs yet)</div>';
histEl.innerHTML = `<div style="font-size:11px;color:var(--muted);padding:4px 0">${esc(t('cron_no_runs_yet'))}</div>`;
} else {
histEl.innerHTML = data.outputs.map((out, i) => {
const ts = out.filename.replace('.md','').replace(/_/g,' ');
@@ -228,9 +228,9 @@ async function loadCronHistory(jobId, btn) {
}).join('');
}
histEl.style.display = '';
if (btn) btn.textContent = 'Hide runs';
if (btn) btn.textContent = t('cron_hide_runs');
} catch(e) {
if (btn) btn.textContent = 'All runs';
if (btn) btn.textContent = t('cron_all_runs');
}
}
@@ -242,25 +242,25 @@ function toggleCron(id) {
async function cronRun(id) {
try {
await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job triggered');
showToast(t('cron_job_triggered'));
setTimeout(() => loadCronOutput(id), 5000);
} catch(e) { showToast('Run failed: ' + e.message, 4000); }
} catch(e) { showToast(t('failed_colon') + e.message, 4000); }
}
async function cronPause(id) {
try {
await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job paused');
showToast(t('cron_job_paused'));
await loadCrons();
} catch(e) { showToast('Pause failed: ' + e.message, 4000); }
} catch(e) { showToast(t('failed_colon') + e.message, 4000); }
}
async function cronResume(id) {
try {
await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job resumed');
showToast(t('cron_job_resumed'));
await loadCrons();
} catch(e) { showToast('Resume failed: ' + e.message, 4000); }
} catch(e) { showToast(t('failed_colon') + e.message, 4000); }
}
function cronEditOpen(id, job) {
@@ -284,25 +284,25 @@ async function cronEditSave(id) {
const schedule = $('cron-edit-schedule-' + id).value.trim();
const prompt = $('cron-edit-prompt-' + id).value.trim();
const errEl = $('cron-edit-err-' + id);
if (!schedule) { errEl.textContent = 'Schedule is required'; errEl.style.display = ''; return; }
if (!prompt) { errEl.textContent = 'Prompt is required'; errEl.style.display = ''; return; }
if (!schedule) { errEl.textContent = t('cron_schedule_required'); errEl.style.display = ''; return; }
if (!prompt) { errEl.textContent = t('cron_prompt_required'); errEl.style.display = ''; return; }
try {
const updates = {job_id: id, schedule, prompt};
if (name) updates.name = name;
await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)});
showToast('Job updated');
showToast(t('cron_job_updated'));
await loadCrons();
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
} catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
}
async function cronDelete(id) {
const _delCron=await showConfirmDialog({title:'Delete cron job',message:'This cannot be undone.',confirmLabel:'Delete',danger:true,focusCancel:true});
const _delCron=await showConfirmDialog({title:t('cron_delete_confirm_title'),message:t('cron_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true});
if(!_delCron) return;
try {
await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job deleted');
showToast(t('cron_job_deleted'));
await loadCrons();
} catch(e) { showToast('Delete failed: ' + e.message, 4000); }
} catch(e) { showToast(t('delete_failed') + e.message, 4000); }
}
function loadTodos() {
@@ -324,7 +324,7 @@ function loadTodos() {
}
}
if (!todos.length) {
panel.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:4px 0">No active task list in this session.</div>';
panel.innerHTML = `<div style="color:var(--muted);font-size:12px;padding:4px 0">${esc(t('todos_no_active'))}</div>`;
return;
}
const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)};
@@ -341,7 +341,7 @@ function loadTodos() {
async function clearConversation() {
if(!S.session) return;
const _clrMsg=await showConfirmDialog({title:'Clear conversation',message:'Clear all messages? This cannot be undone.',confirmLabel:'Clear',danger:true,focusCancel:true});
const _clrMsg=await showConfirmDialog({title:t('clear_conversation_title'),message:t('clear_conversation_message'),confirmLabel:t('clear'),danger:true,focusCancel:true});
if(!_clrMsg) return;
try {
const data = await api('/api/session/clear', {method:'POST',
@@ -351,8 +351,8 @@ async function clearConversation() {
S.toolCalls = [];
syncTopbar();
renderMessages();
showToast('Conversation cleared');
} catch(e) { setStatus('Clear failed: ' + e.message); }
showToast(t('conversation_cleared'));
} catch(e) { setStatus(t('clear_failed') + e.message); }
}
// ── Skills panel ──
@@ -382,7 +382,7 @@ function renderSkills(skills) {
}
const box = $('skillsList');
box.innerHTML = '';
if (!filtered.length) { box.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:12px">No skills match.</div>'; return; }
if (!filtered.length) { box.innerHTML = `<div style="padding:12px;color:var(--muted);font-size:12px">${esc(t('skills_no_match'))}</div>`; return; }
for (const [cat, items] of Object.entries(cats).sort()) {
const sec = document.createElement('div');
sec.className = 'skills-category';
@@ -418,7 +418,7 @@ async function openSkill(name, el) {
const lf = data.linked_files || {};
const categories = Object.entries(lf).filter(([,files]) => files && files.length > 0);
if (categories.length) {
html += '<div class="skill-linked-files"><div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">Linked Files</div>';
html += `<div class="skill-linked-files"><div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">${esc(t('linked_files'))}</div>`;
for (const [cat, files] of categories) {
html += `<div class="skill-linked-section"><h4>${esc(cat)}</h4>`;
for (const f of files) {
@@ -435,7 +435,7 @@ async function openSkill(name, el) {
});
$('previewArea').classList.add('visible');
$('fileTree').style.display = 'none';
} catch(e) { setStatus('Could not load skill: ' + e.message); }
} catch(e) { setStatus(t('skill_load_failed') + e.message); }
}
async function openSkillFile(skillName, filePath) {
@@ -453,7 +453,7 @@ async function openSkillFile(skillName, filePath) {
$('previewCode').textContent = data.content || '';
requestAnimationFrame(() => highlightCode());
}
} catch(e) { setStatus('Could not load file: ' + e.message); }
} catch(e) { setStatus(t('skill_file_load_failed') + e.message); }
}
// ── Skill create/edit form ──
@@ -479,15 +479,15 @@ async function submitSkillSave() {
const content = $('skillFormContent').value;
const errEl = $('skillFormError');
errEl.style.display = 'none';
if (!name) { errEl.textContent = 'Skill name is required'; errEl.style.display = ''; return; }
if (!content.trim()) { errEl.textContent = 'Content is required'; errEl.style.display = ''; return; }
if (!name) { errEl.textContent = t('skill_name_required'); errEl.style.display = ''; return; }
if (!content.trim()) { errEl.textContent = t('content_required'); errEl.style.display = ''; return; }
try {
await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})});
showToast(_editingSkillName ? 'Skill updated' : 'Skill created');
showToast(_editingSkillName ? t('skill_updated') : t('skill_created'));
_skillsData = null;
toggleSkillForm();
await loadSkills();
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
} catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
}
// ── Memory inline edit ──
@@ -498,7 +498,7 @@ function toggleMemoryEdit() {
if (!form) return;
const open = form.style.display !== 'none';
if (open) { form.style.display = 'none'; return; }
$('memEditSection').textContent = 'memory (notes)';
$('memEditSection').textContent = t('memory_notes_label');
$('memEditContent').value = _memoryData ? (_memoryData.memory || '') : '';
$('memEditError').style.display = 'none';
form.style.display = '';
@@ -515,10 +515,10 @@ async function submitMemorySave() {
errEl.style.display = 'none';
try {
await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: 'memory', content})});
showToast('Memory saved');
showToast(t('memory_saved'));
closeMemoryEdit();
await loadMemory(true);
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
} catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
}
// ── Workspace management ──
@@ -550,7 +550,7 @@ function syncWorkspaceDisplays(){
if(composerLabel) composerLabel.textContent=label;
if(composerChip){
composerChip.disabled=!hasSession;
composerChip.title=hasSession?ws:'No active workspace';
composerChip.title=hasSession?ws:t('no_workspace');
composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open')));
}
}
@@ -610,15 +610,15 @@ function renderWorkspaceDropdownInto(dd, workspaces, currentWs){
}
dd.appendChild(document.createElement('div')).className='ws-divider';
dd.appendChild(_renderWorkspaceAction(
'Choose workspace path',
'Add a validated path and switch this conversation',
t('workspace_choose_path'),
t('workspace_choose_path_meta'),
li('folder',12),
()=>promptWorkspacePath()
));
const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div);
dd.appendChild(_renderWorkspaceAction(
'Manage workspaces',
'Open the Spaces panel',
t('workspace_manage'),
t('workspace_manage_meta'),
li('settings',12),
()=>{closeWsDropdown();mobileSwitchPanel('workspaces');}
));
@@ -693,19 +693,19 @@ function renderWorkspacesPanel(workspaces){
<div class="ws-row-path">${esc(w.path)}</div>
</div>
<div class="ws-row-actions">
<button class="ws-action-btn" title="Use in current session" onclick="switchToWorkspace('${esc(w.path)}','${esc(w.name)}')">${li('arrow-right',12)} Use</button>
<button class="ws-action-btn danger" title="Remove" onclick="removeWorkspace('${esc(w.path)}')">${li('x',12)}</button>
<button class="ws-action-btn" title="${esc(t('workspace_use_title'))}" onclick="switchToWorkspace('${esc(w.path)}','${esc(w.name)}')">${li('arrow-right',12)} ${esc(t('workspace_use'))}</button>
<button class="ws-action-btn danger" title="${esc(t('remove'))}" onclick="removeWorkspace('${esc(w.path)}')">${li('x',12)}</button>
</div>`;
panel.appendChild(row);
}
const addRow=document.createElement('div');addRow.className='ws-add-row';
addRow.innerHTML=`
<input id="wsAddInput" placeholder="Add workspace path (e.g. /home/user/my-project)" style="flex:1;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;">
<button class="ws-action-btn" onclick="addWorkspace()">${li('plus',12)} Add</button>`;
<input id="wsAddInput" placeholder="${esc(t('workspace_add_path_placeholder'))}" style="flex:1;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;">
<button class="ws-action-btn" onclick="addWorkspace()">${li('plus',12)} ${esc(t('add'))}</button>`;
panel.appendChild(addRow);
const hint=document.createElement('div');
hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px';
hint.textContent='Paths are validated as existing directories before saving.';
hint.textContent=t('workspace_paths_validated_hint');
panel.appendChild(hint);
}
@@ -718,28 +718,28 @@ async function addWorkspace(){
_workspaceList=data.workspaces;
renderWorkspacesPanel(data.workspaces);
if(input)input.value='';
showToast('Workspace added');
}catch(e){setStatus('Add failed: '+e.message);}
showToast(t('workspace_added'));
}catch(e){setStatus(t('add_failed')+e.message);}
}
async function removeWorkspace(path){
const _rmWs=await showConfirmDialog({title:'Remove workspace',message:`Remove "${path}"?`,confirmLabel:'Remove',danger:true,focusCancel:true});
const _rmWs=await showConfirmDialog({title:t('workspace_remove_confirm_title'),message:t('workspace_remove_confirm_message',path),confirmLabel:t('remove'),danger:true,focusCancel:true});
if(!_rmWs) return;
try{
const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})});
_workspaceList=data.workspaces;
renderWorkspacesPanel(data.workspaces);
showToast('Workspace removed');
}catch(e){setStatus('Remove failed: '+e.message);}
showToast(t('workspace_removed'));
}catch(e){setStatus(t('remove_failed')+e.message);}
}
async function promptWorkspacePath(){
if(!S.session)return;
const value=await showPromptDialog({
title:'Switch workspace',
message:'Enter an absolute workspace path to add and switch this conversation to.',
confirmLabel:'Switch',
placeholder:'/Users/you/project',
title:t('workspace_switch_prompt_title'),
message:t('workspace_switch_prompt_message'),
confirmLabel:t('workspace_switch_prompt_confirm'),
placeholder:t('workspace_switch_prompt_placeholder'),
value:S.session.workspace||''
});
const path=(value||'').trim();
@@ -748,27 +748,27 @@ async function promptWorkspacePath(){
const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})});
_workspaceList=data.workspaces||[];
const target=_workspaceList[_workspaceList.length-1];
if(!target) throw new Error('Workspace was not added');
if(!target) throw new Error(t('workspace_not_added'));
await switchToWorkspace(target.path,target.name);
}catch(e){
if(String(e.message||'').includes('Workspace already in list')){
showToast('Workspace already saved — choose it from the list');
showToast(t('workspace_already_saved'));
return;
}
showToast('Workspace switch failed: '+e.message);
showToast(t('workspace_switch_failed')+e.message);
}
}
async function switchToWorkspace(path,name){
if(!S.session)return;
if(S.busy){
showToast('Cannot switch workspace while agent is running');
showToast(t('workspace_busy_switch'));
return;
}
if(typeof _previewDirty!=='undefined'&&_previewDirty){
const discard=await showConfirmDialog({
title:'Discard file edits?',
message:'Switching workspaces will discard unsaved file edits in the preview.',
title:t('discard_file_edits_title'),
message:t('discard_file_edits_message'),
confirmLabel:t('discard'),
danger:true
});
@@ -784,8 +784,8 @@ async function switchToWorkspace(path,name){
S.session.workspace=path;
syncTopbar();
await loadDir('.');
showToast(`Switched to ${name||getWorkspaceFriendlyName(path)}`);
}catch(e){setStatus('Switch failed: '+e.message);}
showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path)));
}catch(e){setStatus(t('switch_failed')+e.message);}
}
// ── Profile panel + dropdown ──
@@ -799,7 +799,7 @@ async function loadProfilesPanel() {
_profilesCache = data;
panel.innerHTML = '';
if (!data.profiles || !data.profiles.length) {
panel.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:12px">No profiles found.</div>';
panel.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('profiles_no_profiles'))}</div>`;
return;
}
for (const p of data.profiles) {
@@ -808,22 +808,22 @@ async function loadProfilesPanel() {
const meta = [];
if (p.model) meta.push(p.model.split('/').pop());
if (p.provider) meta.push(p.provider);
if (p.skill_count) meta.push(p.skill_count + ' skill' + (p.skill_count !== 1 ? 's' : ''));
if (p.has_env) meta.push('API keys configured');
if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count));
if (p.has_env) meta.push(t('profile_api_keys_configured'));
const gwDot = p.gateway_running
? '<span class="profile-opt-badge running" title="Gateway running"></span>'
: '<span class="profile-opt-badge stopped" title="Gateway stopped"></span>';
? `<span class="profile-opt-badge running" title="${esc(t('profile_gateway_running'))}"></span>`
: `<span class="profile-opt-badge stopped" title="${esc(t('profile_gateway_stopped'))}"></span>`;
const isActive = p.name === data.active;
const activeBadge = isActive ? '<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">ACTIVE</span>' : '';
const activeBadge = isActive ? `<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">${esc(t('profile_active'))}</span>` : '';
card.innerHTML = `
<div class="profile-card-header">
<div style="min-width:0;flex:1">
<div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5">(default)</span>' : ''}${activeBadge}</div>
${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : '<div class="profile-card-meta">No configuration</div>'}
${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : `<div class="profile-card-meta">${esc(t('profile_no_configuration'))}</div>`}
</div>
<div class="profile-card-actions">
${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="Switch to this profile">Use</button>` : ''}
${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="Delete this profile">${li('x',12)}</button>` : ''}
${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="${esc(t('profile_switch_title'))}">${esc(t('profile_use'))}</button>` : ''}
${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="${esc(t('profile_delete_title'))}">${li('x',12)}</button>` : ''}
</div>
</div>`;
panel.appendChild(card);
@@ -844,7 +844,7 @@ function renderProfileDropdown(data) {
opt.className = 'profile-opt' + (p.name === active ? ' active' : '');
const meta = [];
if (p.model) meta.push(p.model.split('/').pop());
if (p.skill_count) meta.push(p.skill_count + ' skills');
if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count));
const gwDot = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;
const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';
opt.innerHTML = `<div class="profile-opt-name">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5;font-weight:400">(default)</span>' : ''}${checkmark}</div>` +
@@ -859,7 +859,7 @@ function renderProfileDropdown(data) {
// Divider + Manage link
const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div);
const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage';
mgmt.innerHTML = `${li('settings',12)} Manage profiles`;
mgmt.innerHTML = `${li('settings',12)} ${esc(t('manage_profiles'))}`;
mgmt.onclick = () => { closeProfileDropdown(); mobileSwitchPanel('profiles'); };
dd.appendChild(mgmt);
}
@@ -876,7 +876,7 @@ function toggleProfileDropdown() {
_positionProfileDropdown();
const chip=$('profileChip');
if(chip) chip.classList.add('active');
}).catch(e => { showToast('Failed to load profiles'); });
}).catch(e => { showToast(t('profiles_load_failed')); });
}
function closeProfileDropdown() {
@@ -894,7 +894,7 @@ window.addEventListener('resize',()=>{
});
async function switchToProfile(name) {
if (S.busy) { showToast('Cannot switch profiles while agent is running'); return; }
if (S.busy) { showToast(t('profiles_busy_switch')); return; }
// Determine whether the current session has any messages.
// A session with messages is "in progress" and belongs to the current profile —
@@ -948,12 +948,12 @@ async function switchToProfile(name) {
// Start a new session for the new profile so nothing gets cross-tagged.
await newSession(false);
await renderSessionList();
showToast('Switched to profile: ' + name + ' — new conversation started');
showToast(t('profile_switched_new_conversation', name));
} else {
// No messages yet — just refresh the list and topbar in place
await renderSessionList();
syncTopbar();
showToast('Switched to profile: ' + name);
showToast(t('profile_switched', name));
}
// ── Sidebar panels ─────────────────────────────────────────────────────
@@ -963,7 +963,7 @@ async function switchToProfile(name) {
if (_currentPanel === 'profiles') await loadProfilesPanel();
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
} catch (e) { showToast('Switch failed: ' + e.message); }
} catch (e) { showToast(t('switch_failed') + e.message); }
}
function toggleProfileForm() {
@@ -985,13 +985,13 @@ async function submitProfileCreate() {
const name = ($('profileFormName').value || '').trim().toLowerCase();
const cloneConfig = $('profileFormClone').checked;
const errEl = $('profileFormError');
if (!name) { errEl.textContent = 'Name is required'; errEl.style.display = ''; return; }
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) { errEl.textContent = 'Lowercase letters, numbers, hyphens, underscores only'; errEl.style.display = ''; return; }
if (!name) { errEl.textContent = t('name_required'); errEl.style.display = ''; return; }
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) { errEl.textContent = t('profile_name_rule'); errEl.style.display = ''; return; }
try {
const baseUrl = (($('profileFormBaseUrl') && $('profileFormBaseUrl').value) || '').trim();
const apiKey = (($('profileFormApiKey') && $('profileFormApiKey').value) || '').trim();
if (baseUrl && !/^https?:\/\//.test(baseUrl)) {
errEl.textContent = 'Base URL must start with http:// or https://'; errEl.style.display = ''; return;
errEl.textContent = t('profile_base_url_rule'); errEl.style.display = ''; return;
}
const payload = { name, clone_config: cloneConfig };
if (baseUrl) payload.base_url = baseUrl;
@@ -999,18 +999,21 @@ async function submitProfileCreate() {
await api('/api/profile/create', { method: 'POST', body: JSON.stringify(payload) });
toggleProfileForm();
await loadProfilesPanel();
showToast('Profile created: ' + name);
} catch (e) { errEl.textContent = e.message || 'Create failed'; errEl.style.display = ''; }
showToast(t('profile_created', name));
} catch (e) {
errEl.textContent = e.message || t('create_failed');
errEl.style.display = '';
}
}
async function deleteProfile(name) {
const _delProf=await showConfirmDialog({title:`Delete profile "${name}"?`,message:'This removes all config, skills, memory, and sessions for this profile.',confirmLabel:'Delete',danger:true,focusCancel:true});
const _delProf=await showConfirmDialog({title:t('profile_delete_confirm_title',name),message:t('profile_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true});
if(!_delProf) return;
try {
await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) });
await loadProfilesPanel();
showToast('Profile deleted: ' + name);
} catch (e) { showToast('Delete failed: ' + e.message); }
showToast(t('profile_deleted', name));
} catch (e) { showToast(t('delete_failed') + e.message); }
}
// ── Memory panel ──
@@ -1023,23 +1026,23 @@ async function loadMemory(force) {
panel.innerHTML = `
<div class="memory-section">
<div class="memory-section-title">
<span style="display:inline-flex;align-items:center;gap:6px">${li('brain',14)} My Notes</span>
<span style="display:inline-flex;align-items:center;gap:6px">${li('brain',14)} ${esc(t('my_notes'))}</span>
<span class="memory-mtime">${fmtTime(data.memory_mtime)}</span>
</div>
${data.memory
? `<div class="memory-content preview-md">${renderMd(data.memory)}</div>`
: '<div class="memory-empty">No notes yet.</div>'}
: `<div class="memory-empty">${esc(t('no_notes_yet'))}</div>`}
</div>
<div class="memory-section">
<div class="memory-section-title">
<span style="display:inline-flex;align-items:center;gap:6px">${li('user',14)} User Profile</span>
<span style="display:inline-flex;align-items:center;gap:6px">${li('user',14)} ${esc(t('user_profile'))}</span>
<span class="memory-mtime">${fmtTime(data.user_mtime)}</span>
</div>
${data.user
? `<div class="memory-content preview-md">${renderMd(data.user)}</div>`
: '<div class="memory-empty">No profile yet.</div>'}
: `<div class="memory-empty">${esc(t('no_profile_yet'))}</div>`}
</div>`;
} catch(e) { panel.innerHTML = `<div style="color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
} catch(e) { panel.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`; }
}
// Drag and drop
@@ -1074,12 +1077,12 @@ function switchSettingsSection(name){
function _syncHermesPanelSessionActions(){
const hasSession=!!S.session;
const visibleMessages=hasSession?(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length:0;
const title=hasSession?(S.session.title||'Untitled'):'No active conversation selected.';
const title=hasSession?(S.session.title||t('untitled')):t('active_conversation_none');
const meta=$('hermesSessionMeta');
if(meta){
meta.textContent=hasSession
? `${title} · ${visibleMessages} message${visibleMessages===1?'':'s'}`
: 'No active conversation selected.';
? t('active_conversation_meta', title, visibleMessages)
: t('active_conversation_none');
}
const setDisabled=(id,disabled)=>{
const el=$(id);
@@ -1148,10 +1151,10 @@ function _showSettingsUnsavedBar(){
bar = document.createElement('div');
bar.id = 'settingsUnsavedBar';
bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.3);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:13px;';
bar.innerHTML = '<span style="color:var(--text)">You have unsaved changes.</span>'
bar.innerHTML = `<span style="color:var(--text)">${esc(t('settings_unsaved_changes'))}</span>`
+ '<span style="display:flex;gap:8px">'
+ '<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">Discard</button>'
+ '<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">Save</button>'
+ `<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">${esc(t('discard'))}</button>`
+ `<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">${esc(t('save'))}</button>`
+ '</span>';
const body = document.querySelector('.settings-main') || document.querySelector('.settings-body') || document.querySelector('.settings-panel');
if(body) body.prepend(bar);
@@ -1171,8 +1174,14 @@ function _markSettingsDirty(){
async function loadSettingsPanel(){
try{
const settings=await api('/api/settings');
// Apply server-persisted locale immediately (overrides localStorage boot default)
if(settings.language && typeof setLocale==='function') setLocale(settings.language);
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
// Keep settings modal and current page strings in sync with the resolved locale.
if(typeof setLocale==='function'){
setLocale(resolvedLanguage);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
}
// Populate model dropdown from /api/models
const modelSel=$('settingsModel');
if(modelSel){
@@ -1210,7 +1219,7 @@ async function loadSettingsPanel(){
langSel.appendChild(opt);
}
}
langSel.value=settings.language||'en';
langSel.value=resolvedLanguage;
langSel.addEventListener('change',_markSettingsDirty,{once:false});
}
const showUsageCb=$('settingsShowTokenUsage');
@@ -1287,7 +1296,7 @@ async function saveSettings(andClose){
_settingsDirty=false; _settingsThemeOnOpen=theme;
_hideSettingsPanel();
return;
}catch(e){showToast('Save failed: '+e.message);return;}
}catch(e){showToast(t('settings_save_failed')+e.message);return;}
}
try{
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
@@ -1319,23 +1328,23 @@ async function signOut(){
await api('/api/auth/logout',{method:'POST',body:'{}'});
window.location.href='/login';
}catch(e){
showToast('Sign out failed: '+e.message);
showToast(t('sign_out_failed')+e.message);
}
}
async function disableAuth(){
const _disAuth=await showConfirmDialog({title:'Disable password protection',message:'Anyone will be able to access this instance.',confirmLabel:'Disable',danger:true,focusCancel:true});
const _disAuth=await showConfirmDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message'),confirmLabel:t('disable'),danger:true,focusCancel:true});
if(!_disAuth) return;
try{
await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})});
showToast('Auth disabled — password protection removed');
showToast(t('auth_disabled'));
// Hide both auth buttons since auth is now off
const disableBtn=$('btnDisableAuth');
if(disableBtn) disableBtn.style.display='none';
const signOutBtn=$('btnSignOut');
if(signOutBtn) signOutBtn.style.display='none';
}catch(e){
showToast('Failed to disable auth: '+e.message);
showToast(t('disable_auth_failed')+e.message);
}
}
@@ -1359,7 +1368,7 @@ function startCronPolling(){
const data=await api(`/api/crons/recent?since=${_cronPollSince}`);
if(data.completions&&data.completions.length>0){
for(const c of data.completions){
showToast(`Cron "${c.name}" ${c.status==='error'?'failed':'completed'}`,4000);
showToast(t('cron_completion_status', c.name, c.status==='error' ? t('status_failed') : t('status_completed')),4000);
_cronPollSince=Math.max(_cronPollSince,c.completed_at);
}
_cronUnreadCount+=data.completions.length;
@@ -1404,7 +1413,7 @@ const _backgroundErrors=[]; // {session_id, title, message, ts}
function trackBackgroundError(sessionId, title, message){
// Only track if user is NOT currently viewing this session
if(S.session&&S.session.session_id===sessionId) return;
_backgroundErrors.push({session_id:sessionId, title:title||'Untitled', message, ts:Date.now()});
_backgroundErrors.push({session_id:sessionId, title:title||t('untitled'), message, ts:Date.now()});
showErrorBanner();
}
@@ -1421,7 +1430,8 @@ function showErrorBanner(){
const latest=_backgroundErrors[0]; // FIFO: show oldest (first) error
if(!latest){banner.style.display='none';return;}
const count=_backgroundErrors.length;
banner.innerHTML=`<span>\u26a0 ${count>1?count+' sessions have':'"'+esc(latest.title)+'" has'} encountered an error</span><div style="display:flex;gap:6px;flex-shrink:0"><button class="reconnect-btn" onclick="navigateToErrorSession()">View</button><button class="reconnect-btn" onclick="dismissErrorBanner()">Dismiss</button></div>`;
const msg=count>1?t('bg_error_multi',count):t('bg_error_single',latest.title);
banner.innerHTML=`<span>\u26a0 ${esc(msg)}</span><div style="display:flex;gap:6px;flex-shrink:0"><button class="reconnect-btn" onclick="navigateToErrorSession()">${esc(t('view'))}</button><button class="reconnect-btn" onclick="dismissErrorBanner()">${esc(t('dismiss'))}</button></div>`;
banner.style.display='';
}

View File

@@ -77,6 +77,42 @@
:root[data-theme="light"] .profile-opt:hover{background:rgba(0,0,0,.05);}
:root[data-theme="light"] .profile-opt.active{background:rgba(45,111,163,.06);}
:root[data-theme="light"] .profile-chip{color:#7a5a90!important;}
/* ── Light theme: Prism syntax token overrides (prism-tomorrow is dark-only) ── */
:root[data-theme="light"] .token.comment,
:root[data-theme="light"] .token.prolog,
:root[data-theme="light"] .token.doctype,
:root[data-theme="light"] .token.cdata{color:#7a7060;font-style:italic;}
:root[data-theme="light"] .token.punctuation{color:#5a4e44;}
:root[data-theme="light"] .token.namespace{opacity:.8;}
:root[data-theme="light"] .token.property,
:root[data-theme="light"] .token.tag,
:root[data-theme="light"] .token.boolean,
:root[data-theme="light"] .token.number,
:root[data-theme="light"] .token.constant,
:root[data-theme="light"] .token.symbol,
:root[data-theme="light"] .token.deleted{color:#a0290a;}
:root[data-theme="light"] .token.selector,
:root[data-theme="light"] .token.attr-name,
:root[data-theme="light"] .token.string,
:root[data-theme="light"] .token.char,
:root[data-theme="light"] .token.builtin,
:root[data-theme="light"] .token.inserted{color:#276b30;}
:root[data-theme="light"] .token.operator,
:root[data-theme="light"] .token.entity,
:root[data-theme="light"] .token.url,
:root[data-theme="light"] .language-css .token.string,
:root[data-theme="light"] .style .token.string{color:#5a3e8a;}
:root[data-theme="light"] .token.atrule,
:root[data-theme="light"] .token.attr-value,
:root[data-theme="light"] .token.keyword{color:#2d6fa3;}
:root[data-theme="light"] .token.function,
:root[data-theme="light"] .token.class-name{color:#7a3a00;}
:root[data-theme="light"] .token.regex,
:root[data-theme="light"] .token.important,
:root[data-theme="light"] .token.variable{color:#8a4a00;}
:root[data-theme="light"] .token.important,
:root[data-theme="light"] .token.bold{font-weight:bold;}
:root[data-theme="light"] .token.italic{font-style:italic;}
:root[data-theme="light"] .nav-tab:hover::after{background:var(--surface);border-color:rgba(45,111,163,.25);color:#2d6fa3;}
:root[data-theme="light"] .cron-status.disabled{background:rgba(0,0,0,.05);}
:root[data-theme="light"] .cron-btn{background:rgba(0,0,0,.04);}
@@ -382,6 +418,8 @@
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
.msg-body pre{background:var(--code-bg);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;}
.msg-body pre code{background:none;padding:0;border-radius:0;color:var(--pre-text);font-size:13px;line-height:1.6;}
/* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */
.msg-body pre[class*="language-"],.msg-body pre code[class*="language-"]{background:var(--code-bg) !important;}
.pre-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:8px 16px 8px;background:var(--input-bg);border-radius:10px 10px 0 0;border:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:6px;}
.pre-header::before{content:'';width:8px;height:8px;border-radius:50%;background:var(--muted);opacity:.4;}
.pre-header+pre{border-radius:0 0 10px 10px;border-top:none;margin-top:0;}
@@ -536,6 +574,8 @@
.preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
.preview-md pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;}
.preview-md pre code{background:none;padding:0;color:var(--pre-text);font-size:11.5px;line-height:1.55;}
/* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */
.preview-md pre[class*="language-"],.preview-md pre code[class*="language-"]{background:var(--code-bg) !important;}
.preview-md blockquote{border-left:3px solid var(--blue);padding-left:12px;color:var(--muted);font-style:italic;margin:8px 0;}
.preview-md strong{color:var(--strong);font-weight:600;}.preview-md em{color:var(--em);}
.preview-md a{color:var(--blue);text-decoration:underline;}
@@ -557,7 +597,6 @@
.mobile-hamburger{display:none;}
.mobile-files-btn{display:none!important;}
.mobile-overlay{display:none;}
.mobile-bottom-nav{display:none;}
@media(min-width:901px){
.layout.workspace-panel-collapsed .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
@@ -593,20 +632,6 @@
box-shadow:-4px 0 24px rgba(0,0,0,.4);}
.rightpanel.mobile-open{right:0;}
.rightpanel .resize-handle{display:none;}
/* Bottom navigation bar */
.mobile-bottom-nav{display:flex;position:fixed;bottom:0;left:0;right:0;
background:var(--sidebar);border-top:1px solid var(--border);
z-index:150;padding:4px 0 env(safe-area-inset-bottom,0);
justify-content:space-around;align-items:center;}
.mobile-nav-btn{display:flex;flex-direction:column;align-items:center;gap:2px;
background:none;border:none;color:var(--muted);font-size:9px;padding:6px 4px;
cursor:pointer;min-width:44px;min-height:44px;justify-content:center;
-webkit-tap-highlight-color:transparent;transition:color .15s;}
.mobile-nav-btn.active{color:var(--blue);}
.mobile-nav-btn:hover{color:var(--text);}
.mobile-nav-btn svg{flex-shrink:0;}
/* Hide sidebar nav tabs (replaced by bottom nav) */
.sidebar-nav{display:none;}
/* Keep the Hermes control available at the bottom of the mobile sidebar */
.sidebar-bottom{display:block;padding:10px;}
/* Topbar adjustments */
@@ -620,13 +645,10 @@
.settings-tab{flex-shrink:0;}
.settings-main{padding:18px 16px;}
.hermes-action-grid{grid-template-columns:1fr;}
/* Messages area — account for bottom nav */
.messages{padding-bottom:60px;}
.messages-inner{padding:12px 10px 20px;}
.msg-body{padding-left:0;max-width:100%;}
.msg-role{font-size:12px;}
/* Composer — above bottom nav */
.composer-wrap{padding:8px 10px 12px!important;margin-bottom:56px;}
.composer-wrap{padding:8px 10px 12px!important;}
.composer-box{border-radius:12px;}
.composer-box textarea{font-size:16px;min-height:40px;}
.composer-footer{padding:6px 8px 8px!important;gap:8px;}

View File

@@ -411,7 +411,12 @@ function renderMd(raw){
const id='mermaid-'+Math.random().toString(36).slice(2,10);
return `<div class="mermaid-block" data-mermaid-id="${id}">${esc(code.trim())}</div>`;
});
s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';return `${h}<pre><code>${esc(code.replace(/\n$/,''))}</code></pre>`;});
s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{
const normalizedLang=(lang||'').trim().toLowerCase();
const h=normalizedLang?`<div class="pre-header">${esc(normalizedLang)}</div>`:'';
const langAttr=normalizedLang?` class="language-${esc(normalizedLang)}"`:'';
return `${h}<pre><code${langAttr}>${esc(code.replace(/\n$/,''))}</code></pre>`;
});
s=s.replace(/`([^`\n]+)`/g,(_,c)=>`<code>${esc(c)}</code>`);
// inlineMd: process bold/italic/code/links within a single line of text.
// Used inside list items and blockquotes where the text may already contain

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,25 @@
"""Regression tests for fenced code block syntax highlighting."""
from pathlib import Path
UI_JS = Path(__file__).resolve().parent.parent / "static" / "ui.js"
def _read_ui_js() -> str:
return UI_JS.read_text()
def test_fenced_code_blocks_add_prism_language_class():
js = _read_ui_js()
assert 'class="language-${esc(normalizedLang)}"' in js, (
"Fenced code blocks should add Prism language-* classes so syntax highlighting works"
)
def test_fenced_code_blocks_keep_existing_pre_header_layout():
js = _read_ui_js()
assert 'return `${h}<pre><code${langAttr}>${esc(code.replace(/\\n$/,' in js, (
"The syntax-highlight fix should preserve the existing fenced code block layout"
)
assert '<div class="code-block">' not in js, (
"This fix should not introduce a new wrapper around fenced code blocks"
)

View File

@@ -0,0 +1,262 @@
import json
import pathlib
import re
import subprocess
import textwrap
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
def _run_i18n_case(script_expr: str) -> dict:
wrapped_expr = f"(() => ({script_expr}))()"
script = textwrap.dedent(
f"""
const fs = require('fs');
const vm = require('vm');
const src = fs.readFileSync({json.dumps(str(REPO_ROOT / "static" / "i18n.js"))}, 'utf8');
const storage = {{}};
const ctx = {{
localStorage: {{
getItem: (k) => Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null,
setItem: (k, v) => {{ storage[k] = String(v); }},
}},
document: {{
documentElement: {{ lang: '' }},
querySelectorAll: () => [],
}},
}};
vm.createContext(ctx);
vm.runInContext(src, ctx);
const out = vm.runInContext({json.dumps(wrapped_expr)}, ctx);
process.stdout.write(JSON.stringify(out));
"""
)
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(proc.stdout)
def _extract_call_arglists(src: str, fn_name: str) -> list[str]:
token = f"{fn_name}("
out = []
search_from = 0
while True:
start = src.find(token, search_from)
if start < 0:
return out
i = start + len(token)
depth = 1
in_single = False
in_double = False
in_backtick = False
escape = False
while i < len(src):
ch = src[i]
if escape:
escape = False
i += 1
continue
if in_single:
if ch == "\\":
escape = True
elif ch == "'":
in_single = False
i += 1
continue
if in_double:
if ch == "\\":
escape = True
elif ch == '"':
in_double = False
i += 1
continue
if in_backtick:
if ch == "\\":
escape = True
elif ch == "`":
in_backtick = False
i += 1
continue
if ch == "'":
in_single = True
elif ch == '"':
in_double = True
elif ch == "`":
in_backtick = True
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
out.append(src[start + len(token) : i])
break
i += 1
search_from = start + len(token)
def _split_top_level_args(arg_src: str) -> list[str]:
args = []
cur = []
paren = 0
brace = 0
bracket = 0
in_single = False
in_double = False
in_backtick = False
escape = False
for ch in arg_src:
if escape:
cur.append(ch)
escape = False
continue
if in_single:
cur.append(ch)
if ch == "\\":
escape = True
elif ch == "'":
in_single = False
continue
if in_double:
cur.append(ch)
if ch == "\\":
escape = True
elif ch == '"':
in_double = False
continue
if in_backtick:
cur.append(ch)
if ch == "\\":
escape = True
elif ch == "`":
in_backtick = False
continue
if ch == "'":
in_single = True
cur.append(ch)
continue
if ch == '"':
in_double = True
cur.append(ch)
continue
if ch == "`":
in_backtick = True
cur.append(ch)
continue
if ch == "(":
paren += 1
cur.append(ch)
continue
if ch == ")":
paren -= 1
cur.append(ch)
continue
if ch == "{":
brace += 1
cur.append(ch)
continue
if ch == "}":
brace -= 1
cur.append(ch)
continue
if ch == "[":
bracket += 1
cur.append(ch)
continue
if ch == "]":
bracket -= 1
cur.append(ch)
continue
if ch == "," and paren == 0 and brace == 0 and bracket == 0:
args.append("".join(cur).strip())
cur = []
continue
cur.append(ch)
if cur:
args.append("".join(cur).strip())
return args
def _has_precedence_call(src: str, first_arg: str) -> bool:
expected_second = {
"localStorage.getItem('hermes-lang')",
'localStorage.getItem("hermes-lang")',
}
for arg_src in _extract_call_arglists(src, "resolvePreferredLocale"):
args = _split_top_level_args(arg_src)
if len(args) < 2:
continue
first = re.sub(r"\s+", "", args[0])
second = re.sub(r"\s+", "", args[1])
if first == first_arg and second in expected_second:
return True
return False
def test_i18n_exposes_locale_resolvers():
assert "function resolveLocale(" in I18N_JS
assert "function resolvePreferredLocale(" in I18N_JS
def test_locale_alias_resolution_and_precedence_logic():
result = _run_i18n_case(
"""
{
zhCn: resolveLocale('zh-CN'),
zhTw: resolveLocale('zh_TW'),
enUs: resolveLocale('EN-us'),
esMx: resolveLocale('es-MX'),
bad: resolveLocale('xx-YY'),
preferred1: resolvePreferredLocale('zh-CN', 'en'),
preferred2: resolvePreferredLocale('xx-YY', 'zh-Hant'),
preferred3: resolvePreferredLocale('', 'xx-YY'),
}
"""
)
assert result["zhCn"] == "zh"
assert result["zhTw"] == "zh-Hant"
assert result["enUs"] == "en"
assert result["esMx"] == "es"
assert result["bad"] is None
assert result["preferred1"] == "zh"
assert result["preferred2"] == "zh-Hant"
assert result["preferred3"] == "en"
def test_set_locale_normalizes_alias_and_persists_canonical_key():
result = _run_i18n_case(
"""
{
...(setLocale('zh-CN'), {}),
saved: localStorage.getItem('hermes-lang'),
htmlLang: document.documentElement.lang,
}
"""
)
assert result["saved"] == "zh"
assert result["htmlLang"] == "zh-CN"
def test_boot_and_settings_panel_use_shared_locale_precedence():
assert _has_precedence_call(BOOT_JS, "s.language")
assert _has_precedence_call(PANELS_JS, "settings.language")

View File

@@ -0,0 +1,68 @@
import json
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8788"
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def get_raw(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return r.read().decode(), r.status
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
BASE + path, 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
def _current_language():
settings, status = get("/api/settings")
assert status == 200
return settings.get("language") or "en"
def test_login_page_uses_simplified_chinese_for_zh_cn_alias():
prev_lang = _current_language()
try:
saved, status = post("/api/settings", {"language": "zh-CN"})
assert status == 200
assert saved.get("language") == "zh-CN"
html, status2 = get_raw("/login")
assert status2 == 200
assert 'lang="zh-CN"' in html
assert "\u767b\u5f55" in html
assert "\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528" in html
finally:
restored, restore_status = post("/api/settings", {"language": prev_lang})
assert restore_status == 200
assert restored.get("language") == prev_lang
def test_login_page_uses_traditional_chinese_for_zh_hant():
prev_lang = _current_language()
try:
saved, status = post("/api/settings", {"language": "zh-Hant"})
assert status == 200
assert saved.get("language") == "zh-Hant"
html, status2 = get_raw("/login")
assert status2 == 200
assert 'lang="zh-TW"' in html
assert "\u8f38\u5165\u5bc6\u78bc\u7e7c\u7e8c\u4f7f\u7528" in html
assert "\u5bc6\u78bc\u932f\u8aa4" in html
finally:
restored, restore_status = post("/api/settings", {"language": prev_lang})
assert restore_status == 200
assert restored.get("language") == prev_lang

View File

@@ -9,7 +9,7 @@ They are static checks (no server needed) that catch common regressions:
- Right panel slide-over markup and CSS intact
- Profile dropdown not clipped by overflow on mobile
- Composer footer chips scroll correctly on narrow viewports
- Mobile bottom nav and overlay markup present
- Mobile sidebar navigation stays available on phones
- No full-viewport overflow that would break scroll
Run as part of the standard test suite:
@@ -61,12 +61,20 @@ def test_mobile_overlay_present():
".mobile-overlay CSS rule missing from style.css"
def test_mobile_bottom_nav_present():
"""Mobile bottom navigation bar must be present."""
assert "mobile-bottom-nav" in HTML or "mobile-nav-btn" in HTML, \
"Mobile bottom nav (.mobile-bottom-nav or .mobile-nav-btn) missing from index.html"
assert "mobile-bottom-nav" in CSS, \
".mobile-bottom-nav CSS rule missing from style.css"
def test_sidebar_nav_present():
"""Sidebar top navigation tabs must be present."""
assert 'class="sidebar-nav"' in HTML, \
".sidebar-nav missing from index.html"
assert ".sidebar-nav{" in CSS or ".sidebar-nav {" in CSS, \
".sidebar-nav CSS rule missing from style.css"
def test_mobile_does_not_hide_sidebar_nav():
"""Phone breakpoint must keep the sidebar top navigation visible."""
mobile_block = re.search(r'@media\(max-width:640px\)\{(.*)\n\s*\}', CSS, re.DOTALL)
assert mobile_block, "Missing @media(max-width:640px) block in style.css"
assert ".sidebar-nav{display:none" not in mobile_block.group(1).replace(" ", ""), \
".sidebar-nav must stay visible on mobile"
def test_mobile_files_button_present():
@@ -222,34 +230,20 @@ def test_composer_textarea_font_size_mobile():
# ── Profiles button in mobile bottom nav ─────────────────────────────────────
# ── Sidebar tabs on mobile ───────────────────────────────────────────────────
def test_mobile_profiles_button_present():
"""Mobile bottom nav must include a Profiles button (PR #265)."""
assert 'data-panel="profiles"' in HTML and 'mobileSwitchPanel' in HTML, \
"Mobile nav must have a Profiles button with data-panel='profiles' and mobileSwitchPanel"
def test_profiles_sidebar_tab_present():
"""Sidebar tab strip must include Profiles."""
assert 'class="nav-tab" data-panel="profiles"' in HTML, \
"Sidebar nav must have a Profiles tab"
def test_mobile_profiles_button_uses_mobileSwitchPanel():
"""Profiles mobile nav button must use mobileSwitchPanel, not raw switchPanel."""
import re
match = re.search(
r'<button[^>]*mobile-nav-btn[^>]*data-panel="profiles"[^>]*>|'
r'<button[^>]*data-panel="profiles"[^>]*mobile-nav-btn[^>]*>',
HTML
)
assert match, "Could not find mobile-nav-btn with data-panel='profiles'"
btn_html = HTML[match.start():match.start()+300]
assert "mobileSwitchPanel('profiles')" in btn_html, \
"Profiles mobile nav button must call mobileSwitchPanel('profiles')"
def test_mobile_profiles_button_is_last_in_nav():
"""Profiles button must appear after Spaces in the mobile bottom nav."""
spaces_pos = HTML.find('data-panel="workspaces"')
profiles_pos = HTML.rfind('data-panel="profiles"')
assert spaces_pos > 0 and profiles_pos > spaces_pos, \
"Profiles button must appear after Spaces button in the mobile nav"
def test_mobile_bottom_nav_removed():
"""The old fixed mobile bottom nav should not be present anymore."""
assert "mobile-bottom-nav" not in HTML, \
"mobile-bottom-nav markup should be removed from index.html"
assert "mobile-bottom-nav" not in CSS, \
"mobile-bottom-nav CSS should be removed from style.css"
# ── Mobile Enter key inserts newline (PR #315, fixes #269) ───────────────────

View File

@@ -107,7 +107,7 @@ def test_crons_output_limit_param(cleanup_test_sessions):
def test_cron_history_button_in_panels_js(cleanup_test_sessions):
src, _ = get_text("/static/panels.js")
assert "loadCronHistory" in src
assert "All runs" in src
assert "cron_all_runs" in src # i18n key (was hardcoded 'All runs' before i18n hardening)
def test_cron_output_snippet_helper(cleanup_test_sessions):
src, _ = get_text("/static/panels.js")