feat: Sprint 21 — mobile responsive layout + Docker support

Mobile responsive (Issue #21):
- Hamburger sidebar: slide-in overlay on mobile (<640px) with backdrop.
  Tap hamburger in topbar to open, tap outside to close. Full session
  list, project chips, all panel content accessible.
- Bottom navigation bar: 5-tab fixed bar (Chat, Tasks, Skills, Memory,
  Spaces) replaces sidebar nav tabs on mobile. iOS-style layout.
  Tapping a tab opens the sidebar overlay with that panel active.
- Right panel slide-over: Files button in topbar chips opens workspace
  panel as a slide-over from the right on mobile/tablet.
- Touch targets: all interactive elements get min 44x44px touch areas.
  Session items, approval buttons, composer buttons all sized for fingers.
- Composer positioned above bottom nav bar with proper spacing.
- Sidebar nav tabs and bottom section hidden on mobile (replaced by
  bottom nav + topbar chips).
- Clicking a session auto-closes the sidebar overlay.
- Desktop layout completely unchanged — all mobile elements are
  display:none by default, only shown inside @media(max-width:640px).

Docker (Issue #7):
- Dockerfile: python:3.12-slim, HERMES_WEBUI_HOST=0.0.0.0, port 8787.
- docker-compose.yml: named volume for state persistence, optional
  ~/.hermes mount for agent features, password env var documented.
- README: Docker quick start section with compose and manual commands.

Tests: 392 passed, 23 pre-existing failures, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-03 10:09:36 -07:00
parent 8cd07d3774
commit d278563e00
7 changed files with 196 additions and 13 deletions

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM python:3.12-slim
LABEL maintainer="nesquena"
LABEL description="Hermes Web UI — browser interface for Hermes Agent"
WORKDIR /app
# Copy source
COPY . /app
# Default to binding all interfaces (required for container networking)
ENV HERMES_WEBUI_HOST=0.0.0.0
ENV HERMES_WEBUI_PORT=8787
# State directory (mount as volume for persistence)
ENV HERMES_WEBUI_STATE_DIR=/data
EXPOSE 8787
CMD ["python", "server.py"]

View File

@@ -35,6 +35,33 @@ That is it. The script will:
--- ---
## Docker
Run with Docker Compose (recommended):
```bash
docker compose up -d
```
Or build and run manually:
```bash
docker build -t hermes-webui .
docker run -d -p 8787:8787 -v ~/.hermes:/root/.hermes:ro hermes-webui
```
Open http://localhost:8787 in your browser.
To enable password protection:
```bash
docker run -d -p 8787:8787 -e HERMES_WEBUI_PASSWORD=your-secret -v ~/.hermes:/root/.hermes:ro hermes-webui
```
Session data persists in a named volume (`hermes-data`) across restarts.
---
## What start.sh discovers automatically ## What start.sh discovers automatically
| Thing | How it finds it | | Thing | How it finds it |

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
version: "3.8"
services:
hermes-webui:
build: .
ports:
- "8787:8787"
volumes:
# Persist session data, settings, and projects across restarts
- hermes-data:/data
# Mount hermes-agent for full agent features (optional)
- ${HERMES_HOME:-~/.hermes}:/root/.hermes:ro
environment:
- HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787
- HERMES_WEBUI_STATE_DIR=/data
# Optional: set a password for remote access
# - HERMES_WEBUI_PASSWORD=your-secret-password
restart: unless-stopped
volumes:
hermes-data:

View File

@@ -8,6 +8,43 @@ async function cancelStream(){
}catch(e){setStatus('Cancel failed: '+e.message);} }catch(e){setStatus('Cancel failed: '+e.message);}
} }
// ── Mobile navigation ──────────────────────────────────────────────────────
function toggleMobileSidebar(){
const sidebar=document.querySelector('.sidebar');
const overlay=$('mobileOverlay');
if(!sidebar)return;
const isOpen=sidebar.classList.contains('mobile-open');
if(isOpen){closeMobileSidebar();}
else{sidebar.classList.add('mobile-open');if(overlay)overlay.classList.add('visible');}
}
function closeMobileSidebar(){
const sidebar=document.querySelector('.sidebar');
const overlay=$('mobileOverlay');
if(sidebar)sidebar.classList.remove('mobile-open');
if(overlay)overlay.classList.remove('visible');
}
function toggleMobileFiles(){
const panel=document.querySelector('.rightpanel');
if(!panel)return;
panel.classList.toggle('mobile-open');
}
function mobileSwitchPanel(name){
// Close sidebar if open, then switch panel
closeMobileSidebar();
// Open sidebar for the selected panel, then close after a moment
const sidebar=document.querySelector('.sidebar');
const overlay=$('mobileOverlay');
if(sidebar){
sidebar.classList.add('mobile-open');
if(overlay)overlay.classList.add('visible');
}
switchPanel(name);
// Update bottom nav active state
document.querySelectorAll('.mobile-nav-btn').forEach(btn=>{
btn.classList.toggle('active',btn.dataset.panel===name);
});
}
$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();}; $('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();};
$('btnAttach').onclick=()=>$('fileInput').click(); $('btnAttach').onclick=()=>$('fileInput').click();

View File

@@ -143,6 +143,9 @@
</aside> </aside>
<main class="main"> <main class="main">
<div class="topbar"> <div class="topbar">
<button class="mobile-hamburger" id="btnHamburger" onclick="toggleMobileSidebar()" title="Menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div> <div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
<div class="topbar-chips"> <div class="topbar-chips">
<div class="chip model" id="modelChip">GPT-5.4 Mini</div> <div class="chip model" id="modelChip">GPT-5.4 Mini</div>
@@ -152,6 +155,7 @@
</div> </div>
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">&#128465; Clear</button> <button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">&#128465; Clear</button>
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">&#9881;</button> <button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">&#9881;</button>
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files">&#128193;</button>
</div> </div>
</div> </div>
<div class="messages" id="messages"> <div class="messages" id="messages">
@@ -301,6 +305,29 @@
</div> </div>
</div> </div>
</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>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>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>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>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>Spaces</span>
</button>
</nav>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="/static/ui.js"></script> <script src="/static/ui.js"></script>
<script src="/static/workspace.js"></script> <script src="/static/workspace.js"></script>

View File

@@ -346,6 +346,7 @@ function renderSessionListFromCache(){
_clickTimer=null; _clickTimer=null;
if(_renamingSid) return; if(_renamingSid) return;
await loadSession(s.session_id);renderSessionListFromCache(); await loadSession(s.session_id);renderSessionListFromCache();
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
}, 220); }, 220);
}; };
el.ondblclick=async(e)=>{ el.ondblclick=async(e)=>{

View File

@@ -260,38 +260,87 @@
::-webkit-scrollbar-track{background:transparent} ::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:99px;transition:background .2s} ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:99px;transition:background .2s}
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22)} ::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22)}
@media(max-width:900px){.rightpanel{display:none}} /* ── Desktop: hide mobile-only elements ── */
.mobile-hamburger{display:none;}
.mobile-files-btn{display:none!important;}
.mobile-overlay{display:none;}
.mobile-bottom-nav{display:none;}
@media(max-width:900px){.rightpanel{display:none}.mobile-files-btn{display:inline-flex!important;}}
@media(max-width:640px){ @media(max-width:640px){
.sidebar{display:none} /* ── Sidebar: slide-in overlay instead of hidden ── */
/* Topbar: stack title + chips vertically, allow wrapping */ .sidebar{position:fixed;left:-300px;top:0;bottom:0;width:280px;z-index:200;
.topbar{padding:8px 12px;gap:6px;flex-wrap:wrap;} transition:left .25s ease;box-shadow:4px 0 24px rgba(0,0,0,.4);}
.topbar-left{min-width:0;flex:1 1 100%;} .sidebar.mobile-open{left:0;}
.sidebar .resize-handle{display:none;}
/* Hamburger button */
.mobile-hamburger{display:flex;align-items:center;justify-content:center;
background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;
flex-shrink:0;-webkit-tap-highlight-color:transparent;}
.mobile-hamburger:hover{color:var(--text);}
/* Overlay backdrop */
.mobile-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);
z-index:199;-webkit-tap-highlight-color:transparent;}
.mobile-overlay.visible{display:block;}
/* Files button in topbar */
.mobile-files-btn{display:inline-flex!important;}
/* Right panel: slide-over from right */
.rightpanel{display:flex!important;position:fixed;right:-320px;top:0;bottom:0;
width:300px;z-index:200;transition:right .25s ease;
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;}
/* Hide sidebar bottom section on mobile (model select, workspace) */
.sidebar-bottom{display:none;}
/* Topbar adjustments */
.topbar{padding:8px 12px;gap:8px;}
.topbar-title{font-size:14px;} .topbar-title{font-size:14px;}
.topbar-meta{font-size:10px;} .topbar-meta{display:none;}
.topbar-chips{flex-wrap:wrap;gap:4px;} .topbar-chips{flex-wrap:nowrap;gap:4px;overflow-x:auto;-webkit-overflow-scrolling:touch;}
.topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:3px 8px!important;} .topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:3px 8px!important;white-space:nowrap;}
/* Messages area */ /* Messages area — account for bottom nav */
.messages{padding-bottom:60px;}
.messages-inner{padding:12px 10px 20px;} .messages-inner{padding:12px 10px 20px;}
.msg-body{padding-left:0;max-width:100%;} .msg-body{padding-left:0;max-width:100%;}
.msg-role{font-size:12px;} .msg-role{font-size:12px;}
/* Composer */ /* Composer — above bottom nav */
.composer-wrap{padding:8px 10px 12px!important;} .composer-wrap{padding:8px 10px 12px!important;margin-bottom:56px;}
.composer-box{border-radius:12px;} .composer-box{border-radius:12px;}
.composer-box textarea{font-size:16px;min-height:40px;} .composer-box textarea{font-size:16px;min-height:40px;}
.send-btn{width:32px;height:32px;} .send-btn{width:32px;height:32px;}
/* Touch targets — minimum 44px */
.icon-btn,.mic-btn{min-width:44px;min-height:44px;}
.session-item{min-height:44px;padding:10px 12px;}
/* Empty state */ /* Empty state */
.empty-state h2{font-size:18px;} .empty-state h2{font-size:18px;}
.empty-state p{font-size:13px;} .empty-state p{font-size:13px;}
.suggestion-grid{max-width:100%!important;} .suggestion-grid{max-width:100%!important;}
.suggestion-btn{font-size:12px;padding:8px 10px;} .suggestion{font-size:12px;padding:10px 12px;}
/* Approval card */ /* Approval card */
.approval-card{padding:0 10px 8px;} .approval-card{padding:0 10px 8px;}
.approval-btns{gap:6px;} .approval-btns{gap:6px;}
.approval-btn{padding:5px 10px;font-size:11px;} .approval-btn{padding:8px 12px;font-size:12px;min-height:44px;}
/* Tool cards */ /* Tool cards */
.tool-card{margin-left:0!important;font-size:12px;} .tool-card{margin-left:0!important;font-size:12px;}
/* Settings modal */ /* Settings modal */
.settings-panel{width:95vw;max-width:95vw;} .settings-panel{width:95vw;max-width:95vw;}
/* Login page responsive */
.card{width:90vw;max-width:320px;padding:28px 24px;}
} }
/* ── Workspace dropdown (topbar) ── */ /* ── Workspace dropdown (topbar) ── */