Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
5f5dadc
refactor(ui): add left rail element with tab buttons (desktop only)
aronprins Apr 22, 2026
8d51d83
refactor(ui): three-column layout CSS with rail + sidebar + main
aronprins Apr 22, 2026
826f354
refactor(ui): sync active state across rail and mobile sidebar-nav
aronprins Apr 22, 2026
4c73698
refactor(ui): match rail visual style to hermes-desktop reference
aronprins Apr 22, 2026
5b0ac5a
fix(ui): rail buttons no longer stretch or inherit mobile tab styles
aronprins Apr 22, 2026
71b60c8
fix(ui): allow rail active indicator to render outside button bounds
aronprins Apr 22, 2026
fe16d58
fix(ui): align session search horizontal padding with sidebar sections
aronprins Apr 22, 2026
323ee9d
feat(ui): add settings cog to rail
aronprins Apr 22, 2026
0ff1484
refactor(ui): move settings panes from modal into main content area
aronprins Apr 22, 2026
e1deb0a
fix(ui): remove inline display:none that hid settings main container
aronprins Apr 22, 2026
1b494e2
style(ui): match settings view to hermes-desktop reference
aronprins Apr 22, 2026
f67d445
style(ui): settings sidebar menu matches desktop reference
aronprins Apr 22, 2026
72bb572
style(ui): settings main canvas, cards, form rows match desktop refer…
aronprins Apr 22, 2026
d3cbf84
fix(ui): saving settings stays on the settings page
aronprins Apr 22, 2026
0ccb8da
style(ui): introduce shared .panel-head class (rename from settings-m…
aronprins Apr 22, 2026
676c760
refactor(ui): apply unified panel header to all sidebar panels
aronprins Apr 22, 2026
df97f9a
style(ui): add-item buttons in panel heads are now icon-only plus but…
aronprins Apr 22, 2026
e3da589
style(ui): sidebar search inputs use shared style with leading icon
aronprins Apr 22, 2026
580ecfe
refactor(ui): wrap chat in #mainChat and generalize main-view switching
aronprins Apr 22, 2026
adf52bd
feat(ui): add #mainSkills container with empty state
aronprins Apr 22, 2026
b508d82
refactor(skills): render skill detail in main, not right panel
aronprins Apr 22, 2026
d9b7f86
style(ui): shared .main-view tokens for per-view headers, bodies, and…
aronprins Apr 22, 2026
42af9a1
i18n: add empty-state keys for skills/tasks/workspaces/profiles
aronprins Apr 22, 2026
c119eea
refactor(tasks): render cron detail in main-view with empty state
aronprins Apr 22, 2026
32ce769
refactor(workspaces): render space detail in main-view with empty state
aronprins Apr 22, 2026
d8bdf0a
refactor(profiles): render profile detail in main-view with empty state
aronprins Apr 22, 2026
2fc38f0
refactor(skills): migrate skill create/edit into main-view form
aronprins Apr 22, 2026
36baaaf
refactor(tasks): migrate cron create/edit into main-view form
aronprins Apr 22, 2026
4bcbff4
refactor(workspaces): migrate space add/rename into main-view form
aronprins Apr 22, 2026
952a2df
refactor(profiles): migrate profile create into main-view form
aronprins Apr 22, 2026
d7f7fc4
feat(titlebar): add app titlebar showing current page or conversation
aronprins Apr 23, 2026
6741cc2
refactor(layout): drop chat topbar, split workspace chip into button …
aronprins Apr 23, 2026
9e19a4d
style(main-view-header): tighten vertical padding to 8px
aronprins Apr 23, 2026
b687b02
refactor(memory): migrate personal memory into main-view
aronprins Apr 23, 2026
199e56a
Style fixes.
aronprins Apr 23, 2026
ae38b0f
style(headers): align panel-head and main-view-header underlines at 41px
aronprins Apr 23, 2026
4bef185
style(sidebar): remove box-shadow from mobile slide-in sidebar
aronprins Apr 23, 2026
6bb0311
refactor(titlebar): drop message count sublabel in chat view
aronprins Apr 23, 2026
eb24087
style(toast): reposition top-right with theme-aware variants
aronprins Apr 23, 2026
8f43a49
Fix settings navigation and stale detail refresh
aronprins Apr 23, 2026
a2c6e57
Merge origin/master
aronprins Apr 23, 2026
c9fe7fe
style(providers): restyle provider cards to match hermes-desktop
aronprins Apr 23, 2026
4cc4b90
fix(tests): update refactor-era assertions to current DOM/function names
aronprins Apr 23, 2026
36ffa6d
Merge origin/master into refactor/left-rail-layout
aronprins Apr 24, 2026
ca21f99
style(reasoning-chip): align dropdown with revamp visual system
aronprins Apr 24, 2026
5208638
fix(icons): register undo icon (closes console warning for /btw undo …
aronprins Apr 24, 2026
38b146e
fix(theme): update prism-theme integrity attribute when swapping href
aronprins Apr 24, 2026
9a87612
fix(ui): address PR #899 review — i18n, sidebar layout, skill frontma…
aronprins Apr 24, 2026
0bd1881
fix(memory): keep edit button icon-only (revert label)
aronprins Apr 24, 2026
17457ef
fix(i18n): add missing cron placeholder keys
aronprins Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,7 @@ $('importFileInput').onchange=async(e)=>{
if(res.ok&&res.session){
await loadSession(res.session.session_id);
await renderSessionList();
const overlay=$('settingsOverlay');
if(overlay) overlay.style.display='none';
if(_currentPanel==='settings') switchPanel('chat');
showToast(t('session_imported'));
}
}catch(err){
Expand Down Expand Up @@ -524,9 +523,8 @@ document.addEventListener('keydown',async e=>{
if(typeof skipOnboarding==='function') skipOnboarding();
return;
}
// Close settings overlay if open
const settingsOverlay=$('settingsOverlay');
if(settingsOverlay&&settingsOverlay.style.display!=='none'){_closeSettingsPanel();return;}
// Close settings panel if active
if(_currentPanel==='settings'){_closeSettingsPanel();return;}
// Close workspace dropdown
closeWsDropdown();
// Clear session search
Expand Down Expand Up @@ -648,7 +646,10 @@ function _setResolvedTheme(isDark){
const want=isDark
?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
:'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
if(link.href!==want){ link.href=want; }
const wantIntegrity=isDark
?'sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A'
:'sha384-rCCjoCPCsizaAAYVoz1Q0CmCTvnctK0JkfCSjx7IIxexTBg+uCKtFYycedUjMyA2';
if(link.href!==want){ link.integrity=wantIntegrity; link.href=want; }
}

function _applyTheme(name){
Expand Down
215 changes: 215 additions & 0 deletions static/i18n.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions static/icons.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

466 changes: 252 additions & 214 deletions static/index.html

Large diffs are not rendered by default.

1,813 changes: 1,307 additions & 506 deletions static/panels.js

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -961,11 +961,13 @@ async function deleteSession(sid){
if(remaining.sessions&&remaining.sessions.length){
await loadSession(remaining.sessions[0].session_id);
}else{
$('topbarTitle').textContent=window._botName||'Hermes';
$('topbarMeta').textContent='Start a new conversation';
const _tt=$('topbarTitle');if(_tt)_tt.textContent=window._botName||'Hermes';
const _tm=$('topbarMeta');if(_tm)_tm.textContent='Start a new conversation';
$('msgInner').innerHTML='';
$('emptyState').style.display='';
$('fileTree').innerHTML='';
if(typeof S!=='undefined') S.session=null;
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
}
}
showToast('Conversation deleted');
Expand Down
498 changes: 387 additions & 111 deletions static/style.css

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -966,7 +966,7 @@ function updateQueueBadge(sessionId){
badge.remove();
}
}
function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);}
function showToast(msg,ms,type){const el=$('toast');if(!el)return;const s=String(msg==null?'':msg);let t=type;if(!t){const low=s.toLowerCase();if(/fail|error|denied|invalid|unavailable|no active|no workspace match|no model match|no personalities/.test(low))t='error';else if(/warn|queued|takes effect|skipped|fallback/.test(low))t='warning';else if(/saved|created|imported|restored|switched|set to|updated|duplicated|moved to|renamed|deleted|complete|pinned|archived|cleared|stopped/.test(low))t='success';else t='info';}el.textContent=s;el.className='toast show '+t;clearTimeout(el._t);el._t=setTimeout(()=>{el.classList.remove('show');},ms||2800);}

// ── Shared app dialogs ───────────────────────────────────────────────────────
// showConfirmDialog(opts) and showPromptDialog(opts) replace browser-native dialog calls
Expand Down Expand Up @@ -1384,13 +1384,15 @@ function syncTopbar(){
sidebarName.textContent=t('no_workspace');
}
}
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
return;
}
const sessionTitle=S.session.title||t('untitled');
$('topbarTitle').textContent=sessionTitle;
const _topbarTitle=$('topbarTitle');if(_topbarTitle)_topbarTitle.textContent=sessionTitle;
document.title=sessionTitle+' \u2014 '+(window._botName||'Hermes');
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
$('topbarMeta').textContent=t('n_messages',vis.length);
const _topbarMeta=$('topbarMeta');if(_topbarMeta)_topbarMeta.textContent=t('n_messages',vis.length);
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
// If a profile switch just happened, apply its model rather than the session's stale value.
// S._pendingProfileModel is set by switchToProfile() and cleared here after one application.
const modelOverride=S._pendingProfileModel;
Expand Down Expand Up @@ -1786,7 +1788,7 @@ function renderMessages(){
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(_stripXmlToolCallsDisplay(String(content)));
const isEditableUser=isUser&&rawIdx===lastUserRawIdx;
const editBtn = isEditableUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
const undoBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('undo_exchange')}" onclick="undoLastExchange()">${li('undo-2',13)}</button>` : '';
const undoBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('undo_exchange')}" onclick="undoLastExchange()">${li('undo',13)}</button>` : '';
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
const copyBtn = `<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>`;
const tsVal=m._ts||m.timestamp;
Expand Down
24 changes: 16 additions & 8 deletions tests/test_batch_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,32 +77,40 @@ def _panels_src(self):
def test_cache_busted_on_form_open(self):
src = self._panels_src()
# toggleCronForm should set cache to null unconditionally
# openCronCreate() opens the task create form (renamed from toggleCronForm
# in the main-view refactor). It must null the skills cache before fetching.
m = re.search(
r'function toggleCronForm\(\)\{.*?_cronSkillsCache=null',
r'function openCronCreate\(\)\{.*?_cronSkillsCache\s*=\s*null',
src, re.DOTALL
)
assert m, (
"toggleCronForm must unconditionally null _cronSkillsCache "
"openCronCreate must unconditionally null _cronSkillsCache "
"before fetching skills"
)

def test_cache_not_guarded_by_if_on_open(self):
src = self._panels_src()
# The old guard should be gone
assert "if(!_cronSkillsCache)" not in src, (
"toggleCronForm should not use 'if(!_cronSkillsCache)' guard — "
# openCronCreate must not gate the fetch behind an if(!_cronSkillsCache) guard.
m = re.search(
r'function openCronCreate\(\)\{.*?\}',
src, re.DOTALL
)
assert m, "openCronCreate definition not found"
assert "if(!_cronSkillsCache)" not in m.group(0), (
"openCronCreate should not use 'if(!_cronSkillsCache)' guard — "
"cache must always be busted on open"
)

def test_cache_busted_on_skill_save(self):
src = self._panels_src()
# After submitSkillSave's api() call, _cronSkillsCache must be nulled
# saveSkillForm() is the handler invoked on skill save (renamed from
# submitSkillSave in the main-view refactor; the old name still aliases it).
m = re.search(
r'async function submitSkillSave\(\).*?_skillsData\s*=\s*null.*?_cronSkillsCache\s*=\s*null',
r'async function saveSkillForm\(\).*?_skillsData\s*=\s*null.*?_cronSkillsCache\s*=\s*null',
src, re.DOTALL
)
assert m, (
"_cronSkillsCache must be set to null in submitSkillSave() "
"_cronSkillsCache must be set to null in saveSkillForm() "
"right after _skillsData = null"
)

Expand Down
2 changes: 1 addition & 1 deletion tests/test_cron_refresh_button_835.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def test_refresh_button_sits_next_to_new_job_button(self):
button so the header layout stays tight."""
html = _read("static/index.html")
ref_pos = html.find('id="cronRefreshBtn"')
newjob_pos = html.find('toggleCronForm()')
newjob_pos = html.find('openCronCreate()')
assert ref_pos != -1 and newjob_pos != -1
# Must be close enough to be in the same header row (single SVG-inline
# button can be around 500 chars by itself due to inline styles/attrs).
Expand Down
2 changes: 1 addition & 1 deletion tests/test_issue616.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def test_workspace_suggest_endpoint_is_wired():
def test_spaces_panel_uses_workspace_suggest_autocomplete():
src = pathlib.Path("static/panels.js").read_text(encoding="utf-8")
assert "/api/workspaces/suggest" in src
assert "wsAddSuggestions" in src
assert "workspaceFormPathSuggestions" in src
assert "scheduleWorkspacePathSuggestions" in src
assert "if(!prefix)" in src
assert "dataset.path" in src
Expand Down
100 changes: 100 additions & 0 deletions tests/test_settings_navigation_and_detail_refresh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""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"
)
6 changes: 4 additions & 2 deletions tests/test_sprint10.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,10 @@ 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 "cron_all_runs" in src # i18n key (was hardcoded 'All runs' before i18n hardening)
# After the main-view refactor, cron runs load inline into the detail card
# via _loadCronDetailRuns() instead of a separate "All runs" button.
assert "_loadCronDetailRuns" in src
assert "cron_last_output" in src # i18n key used by the runs card

def test_cron_output_snippet_helper(cleanup_test_sessions):
src, _ = get_text("/static/panels.js")
Expand Down
6 changes: 4 additions & 2 deletions tests/test_sprint34.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,11 @@ def test_control_center_resets_active_section_on_close():


def test_control_center_tab_highlight_on_open():
"""Opening the control center must use settings-tabs for section navigation."""
"""The settings left-rail menu must have a CSS rule that highlights the active section."""
css = open(pathlib.Path(__file__).parent.parent / 'static' / 'style.css').read()
assert 'settings-tabs' in css, 'settings-tabs CSS class for control center tabs missing from style.css'
assert 'side-menu-item' in css, 'side-menu-item CSS class for left-rail nav missing from style.css'
assert '.side-menu-item.active' in css or 'side-menu-item.active' in css, \
'No active-state style for .side-menu-item — sidebar section highlight missing'


# ── apply_onboarding_setup: unsupported/OAuth providers complete gracefully ──
Expand Down
4 changes: 2 additions & 2 deletions tests/test_sprint6.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ def test_index_html_served():
raw, headers, status = get_raw("/")
assert status == 200
assert b"sidebarResize" in raw, "Resize handle not found in HTML"
assert b"cronCreateForm" in raw, "Cron create form not found in HTML"
assert b"btnHermesPanel" in raw, "Hermes control center trigger not found in HTML"
assert b'id="mainTasks"' in raw, "Tasks main-view not found in HTML"
assert b'id="settingsMenu"' in raw, "Settings left-rail menu not found in HTML"
assert b"btnExportJSON" in raw, "Export JSON button not found in HTML"

def test_index_html_file_exists():
Expand Down
Loading