mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 03:30:36 +00:00
970bc1d3fd
refactor(ui): three-column layout with left rail + main-view migration (#899) Unifies the shell into a three-column layout (rail + sidebar + main) matching the hermes-desktop reference, and migrates every per-item detail/edit surface into a shared main-view canvas with consistent headers, empty states, and action buttons. Changes: - New desktop-only left rail (48px) with 8 nav tabs (chat/tasks/skills/memory/workspaces/profiles/todos/settings) - Persistent app titlebar (replaces per-chat topbar), active conversation title shown - All panel detail/create/edit views migrated to #mainSkills, #mainTasks, #mainSettings, #mainWorkspaces, #mainProfiles, #mainMemory - Settings moved out of modal into main-view page; ESC closes it - YAML frontmatter rendered in collapsible <details> block in skill detail - Toasts repositioned from bottom-center to top-right with theme-aware success/error/warning/info variants - Composer workspace chip split into two-button group: files-icon toggles file panel, label opens workspace picker - .settings-menu → .side-menu / .side-menu-item (generalised, shared by memory and settings panels) - i18n: ~25 new keys across en/ru/es/de/zh/zh-Hant for all new form labels, placeholders, and empty states - Mobile: hamburger in titlebar, slide-in sidebar; box-shadow removed from sidebar - New regression test: tests/test_settings_navigation_and_detail_refresh.py (9 tests) Co-authored-by: Aron Prins <pwf.aron@gmail.com>
101 lines
4.1 KiB
Python
101 lines
4.1 KiB
Python
"""Regression coverage for settings navigation and master-detail refresh state."""
|
|
import pathlib
|
|
import re
|
|
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
|
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
|
|
|
|
|
class TestSettingsNavigationGuard:
|
|
"""Leaving Settings through the rail must still honor save/discard semantics."""
|
|
|
|
def test_switch_panel_checks_settings_guard(self):
|
|
assert "if (!opts.bypassSettingsGuard && !_beforePanelSwitch(nextPanel)) return false;" in PANELS_JS, (
|
|
"switchPanel() must consult the settings guard before leaving Settings "
|
|
"so rail/sidebar navigation cannot bypass the unsaved-changes flow"
|
|
)
|
|
|
|
def test_dirty_settings_capture_requested_destination(self):
|
|
m = re.search(
|
|
r"function _beforePanelSwitch\(nextPanel\)\s*\{.*?"
|
|
r"_pendingSettingsTargetPanel = nextPanel \|\| 'chat';.*?"
|
|
r"_showSettingsUnsavedBar\(\);.*?"
|
|
r"return false;",
|
|
PANELS_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert m, (
|
|
"_beforePanelSwitch() must remember the requested destination and "
|
|
"block navigation while settings are dirty"
|
|
)
|
|
|
|
def test_hiding_settings_resumes_pending_target_with_bypass(self):
|
|
m = re.search(
|
|
r"function _hideSettingsPanel\(\)\s*\{.*?"
|
|
r"const target = _consumeSettingsTargetPanel\('chat'\);.*?"
|
|
r"switchPanel\(target, \{bypassSettingsGuard:true\}\);",
|
|
PANELS_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert m, (
|
|
"_hideSettingsPanel() must resume the pending target after save/discard "
|
|
"instead of always falling back to chat"
|
|
)
|
|
|
|
def test_entering_settings_starts_fresh_preview_session(self):
|
|
assert "if (prevPanel !== 'settings' && nextPanel === 'settings') _beginSettingsPanelSession();" in PANELS_JS, (
|
|
"switchPanel() must snapshot the preview baseline when entering Settings "
|
|
"through the main navigation"
|
|
)
|
|
|
|
|
|
class TestMasterDetailRefreshClearsRemovedSelections:
|
|
"""Refreshes must not leave dead detail panes visible after a selection disappears."""
|
|
|
|
def test_tasks_clear_empty_state_detail(self):
|
|
assert "if (_cronMode !== 'create' && _cronMode !== 'edit') _clearCronDetail();" in PANELS_JS, (
|
|
"loadCrons() must clear the detail pane when the jobs list becomes empty"
|
|
)
|
|
|
|
def test_tasks_clear_missing_selected_job(self):
|
|
m = re.search(
|
|
r"if \(_currentCronDetail && _cronMode !== 'create' && _cronMode !== 'edit'\) \{.*?"
|
|
r"if \(refreshed\) _renderCronDetail\(refreshed\);\s*else _clearCronDetail\(\);",
|
|
PANELS_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert m, (
|
|
"loadCrons() must clear the detail pane when the selected job disappears "
|
|
"during refresh"
|
|
)
|
|
|
|
def test_workspaces_clear_missing_selected_workspace(self):
|
|
m = re.search(
|
|
r"if \(_currentWorkspaceDetail && _workspaceMode !== 'create' && _workspaceMode !== 'edit'\) \{.*?"
|
|
r"if \(refreshed\) _renderWorkspaceDetail\(refreshed\);\s*else _clearWorkspaceDetail\(\);",
|
|
PANELS_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert m, (
|
|
"renderWorkspacesPanel() must clear the detail pane when the selected "
|
|
"workspace disappears during refresh"
|
|
)
|
|
|
|
def test_profiles_clear_empty_state_detail(self):
|
|
assert "if (_profileMode !== 'create') _clearProfileDetail();" in PANELS_JS, (
|
|
"loadProfilesPanel() must clear the detail pane when there are no profiles"
|
|
)
|
|
|
|
def test_profiles_clear_missing_selected_profile(self):
|
|
m = re.search(
|
|
r"if \(_currentProfileDetail && _profileMode !== 'create'\) \{.*?"
|
|
r"if \(refreshed\) _renderProfileDetail\(refreshed, data.active\);\s*else _clearProfileDetail\(\);",
|
|
PANELS_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert m, (
|
|
"loadProfilesPanel() must clear the detail pane when the selected "
|
|
"profile disappears during refresh"
|
|
)
|