Merge pull request #40 from nesquena/sprint-21-mobile-docker

Sprint 21: Mobile responsive layout + Docker support (Issues #21, #7)
This commit is contained in:
Nathan Esquenazi
2026-04-03 10:29:04 -07:00
committed by GitHub
10 changed files with 286 additions and 27 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git
.pytest_cache
__pycache__
*.pyc
*.pyo
tests/
.env*

View File

@@ -5,6 +5,38 @@
---
## [v0.23] Sprint 21 -- Mobile Responsive + Docker
*April 3, 2026 | 415 tests*
### Features
- **Mobile responsive layout (Issue #21).** Full mobile experience with
hamburger sidebar (slide-in overlay), bottom navigation bar (5-tab iOS
pattern), and files slide-over panel. Touch targets minimum 44px. Composer
positioned above bottom nav. Session clicks auto-close sidebar. Desktop
layout completely unchanged — all mobile elements hidden via `@media`.
- **Docker support (Issue #7).** Dockerfile (`python:3.12-slim`), docker-compose.yml
with named volume for state persistence, optional `~/.hermes` mount for
agent features. Binds to `127.0.0.1` by default for security.
### Bug Fixes (from review)
- **CSS cascade broke mobile slide-in.** `position:relative` rules after the
media query overrode `position:fixed` on mobile. Wrapped in `@media(min-width:641px)`.
- **mobileSwitchPanel() always reopened sidebar.** Chat tab now closes sidebar
instead of reopening it over the main chat area.
- **Dockerfile missing pip install.** Added `pip install -r requirements.txt`.
- **No .dockerignore.** Added exclusions for `.git`, `tests/`, `.env*`.
- **docker-compose tilde expansion.** Changed `~/.hermes` default to
`${HOME}/.hermes` (Docker Compose doesn't shell-expand `~`).
### Architecture
- Mobile navigation functions in `boot.js`: `toggleMobileSidebar()`,
`closeMobileSidebar()`, `toggleMobileFiles()`, `mobileSwitchPanel()`.
- `sessions.js`: `closeMobileSidebar()` called after session click.
- 69 new CSS lines in `@media(max-width:640px)` block.
- New files: `Dockerfile`, `docker-compose.yml`, `.dockerignore`.
---
## [v0.22] Sprint 20 -- Voice Input + Send Button Polish
*April 3, 2026 | 415 tests*
@@ -716,4 +748,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
---
*Last updated: v0.22, April 3, 2026 | Tests: 415*
*Last updated: v0.23, April 3, 2026 | Tests: 415*

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.12-slim
LABEL maintainer="nesquena"
LABEL description="Hermes Web UI — browser interface for Hermes Agent"
WORKDIR /app
# Copy source
COPY . /app
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# 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,37 @@ 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.
> **Note:** By default, Docker Compose binds to `127.0.0.1` (localhost only).
> To expose on a network, change the port to `"8787:8787"` in `docker-compose.yml`
> and set `HERMES_WEBUI_PASSWORD` to enable authentication.
---
## What start.sh discovers automatically
| Thing | How it finds it |

View File

@@ -1,6 +1,6 @@
# Hermes Web UI -- Forward Sprint Plan
> Current state: v0.22 | 415 tests | Daily driver ready
> Current state: v0.23 | 415 tests | Daily driver ready
> This document plans the path from here to two targets:
>
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
@@ -421,16 +421,36 @@ UX was a low-effort high-impact polish opportunity that pairs naturally.
---
## Sprint 21 -- Mobile Responsive (PLANNED)
## Sprint 21 -- Mobile Responsive + Docker (COMPLETED)
**Theme:** A genuinely good mobile experience, not just responsive CSS.
**Theme:** Mobile experience + containerized deployment.
**Why now:** Issue #21 (mobile) was the most-requested UX gap. Issue #7 (Docker)
enables deployment beyond localhost. Both were achievable without new dependencies.
### Track A: Bugs (from review)
- **CSS cascade broke mobile slide-in.** `position:relative` after the media query
overrode `position:fixed`. Wrapped in `@media(min-width:641px)`.
- **mobileSwitchPanel() always reopened sidebar.** Chat tab now closes it.
- **Dockerfile missing pip install.** Container failed on startup.
- **No .dockerignore.** `.git`, `tests/`, `.env*` leaked into images.
- **docker-compose tilde expansion.** `~` doesn't expand in Compose defaults.
### Track B: Features
- **Collapsible sidebar.** Hamburger menu replaces the always-visible sidebar.
- **Touch-friendly session list.** Tap to navigate, swipe gestures.
- **Right panel as tab.** Files panel hidden by default, accessible via tab.
- **Composer focus behavior.** Expands on focus, keyboard-aware.
- Consider a separate mobile-optimized layout rather than just media queries.
- **Hamburger sidebar.** Slide-in overlay on mobile, tap outside to close.
- **Bottom navigation bar.** 5-tab iOS-style bar replaces sidebar tabs.
- **Files slide-over.** Right panel opens as slide-over from right edge.
- **Touch targets.** Minimum 44px on all interactive elements.
- **Docker support.** Dockerfile, docker-compose.yml, .dockerignore.
### Track C: Architecture
- Mobile nav functions in `boot.js`. Session click auto-closes sidebar.
- 69 new CSS lines scoped to `@media(max-width:640px)`.
- Desktop layout untouched — all mobile elements `display:none` by default.
**Tests:** 0 new (CSS/DOM changes). Total: 415.
**Hermes CLI parity impact:** Low
**Claude parity impact:** High (Claude has mobile layout)
---
@@ -540,5 +560,5 @@ UX was a low-effort high-impact polish opportunity that pairs naturally.
---
*Last updated: April 3, 2026*
*Current version: v0.22 | 415 tests*
*Next sprint: Sprint 21 (Mobile Responsive)*
*Current version: v0.23 | 415 tests*
*Next sprint: Sprint 22 (Multi-Profile Support)*

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
version: "3.8"
services:
hermes-webui:
build: .
ports:
- "127.0.0.1:8787:8787"
volumes:
# Persist session data, settings, and projects across restarts
- hermes-data:/data
# Mount hermes-agent for full agent features (optional)
- ${HERMES_HOME:-${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,48 @@ async function cancelStream(){
}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){
// 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 {
const sidebar=document.querySelector('.sidebar');
const overlay=$('mobileOverlay');
if(sidebar){
sidebar.classList.add('mobile-open');
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=()=>{if(window._micActive)_stopMic();send();};
$('btnAttach').onclick=()=>$('fileInput').click();

View File

@@ -13,7 +13,7 @@
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.22</div></div></div>
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.23</div></div></div>
<div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>
@@ -143,6 +143,9 @@
</aside>
<main class="main">
<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 class="topbar-chips">
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
@@ -152,6 +155,7 @@
</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 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 class="messages" id="messages">
@@ -301,6 +305,29 @@
</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>
<script src="/static/ui.js"></script>
<script src="/static/workspace.js"></script>

View File

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

View File

@@ -260,38 +260,87 @@
::-webkit-scrollbar-track{background:transparent}
::-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)}
@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){
.sidebar{display:none}
/* Topbar: stack title + chips vertically, allow wrapping */
.topbar{padding:8px 12px;gap:6px;flex-wrap:wrap;}
.topbar-left{min-width:0;flex:1 1 100%;}
/* ── Sidebar: slide-in overlay instead of hidden ── */
.sidebar{position:fixed;left:-300px;top:0;bottom:0;width:280px;z-index:200;
transition:left .25s ease;box-shadow:4px 0 24px rgba(0,0,0,.4);}
.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-meta{font-size:10px;}
.topbar-chips{flex-wrap:wrap;gap:4px;}
.topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:3px 8px!important;}
/* Messages area */
.topbar-meta{display:none;}
.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;white-space:nowrap;}
/* 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 */
.composer-wrap{padding:8px 10px 12px!important;}
/* Composer — above bottom nav */
.composer-wrap{padding:8px 10px 12px!important;margin-bottom:56px;}
.composer-box{border-radius:12px;}
.composer-box textarea{font-size:16px;min-height:40px;}
.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 h2{font-size:18px;}
.empty-state p{font-size:13px;}
.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{padding:0 10px 8px;}
.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-card{margin-left:0!important;font-size:12px;}
/* Settings modal */
.settings-panel{width:95vw;max-width:95vw;}
/* Login page responsive */
.card{width:90vw;max-width:320px;padding:28px 24px;}
}
/* ── Workspace dropdown (topbar) ── */
@@ -476,9 +525,14 @@
transition:background .15s;
}
.resize-handle:hover,.resize-handle.dragging{background:rgba(124,185,255,.35);}
/* Desktop-only: position:relative for sidebar/rightpanel resize handles.
Must be scoped to min-width:641px so it doesn't override the mobile
position:fixed slide-in overlay set in the max-width:640px @media block above. */
@media(min-width:641px){
.sidebar{position:relative;}
.sidebar .resize-handle{right:-2px;}
.rightpanel{position:relative;}
}
.sidebar .resize-handle{right:-2px;}
.rightpanel .resize-handle{left:-2px;}
/* Prevent text selection during drag */
body.resizing{user-select:none;cursor:col-resize;}