feat(ui): remove mobile bottom nav on phones

Closes #425:
This commit is contained in:
Aron Prins
2026-04-14 17:13:03 +00:00
committed by Nathan Esquenazi
parent b394efce17
commit db392bd532
9 changed files with 66 additions and 98 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

@@ -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

@@ -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

@@ -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=()=>{

View File

@@ -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

@@ -557,7 +557,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 +592,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 +605,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

@@ -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

@@ -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) ───────────────────