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:
20
Dockerfile
Normal file
20
Dockerfile
Normal 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"]
|
||||||
27
README.md
27
README.md
@@ -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
22
docker-compose.yml
Normal 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:
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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">🗑 Clear</button>
|
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">🗑 Clear</button>
|
||||||
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">⚙</button>
|
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">⚙</button>
|
||||||
|
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files">📁</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>
|
||||||
|
|||||||
@@ -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)=>{
|
||||||
|
|||||||
@@ -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) ── */
|
||||||
|
|||||||
Reference in New Issue
Block a user