WebUI sessions were invisible to 'hermes /insights' because the WebUI
bypasses the gateway and calls AIAgent.run_conversation() directly,
never writing to state.db.
New 'Sync usage to /insights' setting (default: off) that mirrors
WebUI session metadata (tokens, cost, model, title) into state.db
after each turn. Uses absolute token counts to avoid double-counting.
Components:
- api/state_sync.py: bridge module with sync_session_start() and
sync_session_usage(). Uses ensure_session() (idempotent) and
update_token_counts(absolute=True). All wrapped in try/except.
- api/config.py: new 'sync_to_insights' boolean setting
- api/streaming.py: calls sync_session_usage() after s.save()
- api/routes.py: same for the non-streaming chat path
- Settings UI: checkbox toggle with description
Default off because:
- Writing to state.db while CLI/gateway also writes could cause
WAL lock contention on busy systems
- Some users may not want WebUI sessions in /insights stats
Closes#92
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The context indicator in the composer footer now shows real data from
the agent's context compressor instead of hardcoded estimates:
- last_prompt_tokens / context_length (e.g. '12.4k / 200k (6%)')
- Bar color: blue <50%, yellow 50-75%, red >75%
- Hover tooltip shows exact numbers + compression threshold
- Cost appended when available
Backend: streaming.py now reads context_length, threshold_tokens, and
last_prompt_tokens from agent.context_compressor after run_conversation()
and includes them in the usage dict sent with the 'done' SSE event.
This matches the CLI's context window display (the bar that shows
current context vs total window).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The agent's run_conversation() already triggers context compression
internally, but the WebUI was unaware of the side effects:
1. Session ID rotation: compression creates a new session_id inside
the agent. The WebUI kept writing to the old session file, causing
silent data loss. Fix: detect agent.session_id mismatch after
run_conversation(), rename the session file, and update in-memory
caches.
2. No user notification: compression was invisible. Fix: emit a
'compressed' SSE event when compression is detected. Frontend shows
a system message and toast.
3. No manual control: Fix: add /compact slash command that sends a
message to the agent requesting context compression. Shows in the
autocomplete dropdown.
Detection works two ways:
- agent.session_id != original session_id (ID rotation)
- agent.context_compressor.compression_count > 0 (compressor state)
Closes#90
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- routes.py /api/git-info: get_session raises KeyError on miss, does not
return None -- wrap in try/except KeyError to correctly return 404
(PR #82, api/routes.py line 222)
- style.css ctx-bar used undefined --teal CSS variable -- replaced with
--blue which is defined in :root and fits the existing color palette
(PR #83, static/style.css)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Agent review: hardcoded 128000 is wrong for Claude (200k), Gemini (1M),
and smaller models (8k-32k). Added a lookup table keyed by model name
substring covering major families with 128k fallback. TODO comment
for fetching exact values from server.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Agent review feedback: ordered array was constructed but never iterated
(the new code uses groups[] instead). Removed the dead variable.
Added comment noting function hoisting for _renderOneSession.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shows a compact bar + label in the composer footer after the first
response, displaying input/output token counts, context window fill
percentage, and estimated cost. Bar turns yellow >50% and red >75%.
Updates on every response completion via the existing usage data from
the done SSE event. Hidden until first response (no usage data yet).
Inspired by PR #75 (@MartinNielsenDev).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the workspace root is a git repo, a badge in the panel header
shows the current branch name, dirty file count, and ahead/behind
status. Updates on every root directory load.
Backend:
- git_info_for_workspace() in api/workspace.py runs lightweight git
commands (rev-parse, status --porcelain, rev-list) with 3s timeout
- New GET /api/git-info endpoint returns branch, dirty count, modified,
untracked, ahead, behind
Frontend:
- _refreshGitBadge() in workspace.js fetches git info on root load
- Git badge element in panel header shows branch + status
- Badge turns gold when workspace has uncommitted changes
Inspired by PR #75 (@MartinNielsenDev).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Token events from SSE now buffer and render at most once per animation
frame via requestAnimationFrame, instead of calling renderMd() and
writing to the DOM on every single token event.
Before: ~100 tokens/sec = ~100 DOM writes/sec (causes jank on heavy output)
After: ~100 tokens/sec batched to ~60 DOM writes/sec (one per frame)
The change is a small wrapper: _scheduleRender() gates rendering behind
a rAF flag so multiple tokens arriving between frames are batched into
a single renderMd() + scrollIfPinned() call.
Inspired by PR #75 (@MartinNielsenDev).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Date group headers (Pinned, Today, Yesterday, Earlier) are now clickable
to collapse/expand their session lists. Collapsed state persists to
localStorage across page reloads.
- Refactored renderSessionListFromCache to group sessions first, then
render groups with collapsible wrappers
- Extracted _renderOneSession() helper for reuse within group bodies
- Chevron indicator rotates -90deg when collapsed
- Pinned group header keeps its gold color
Inspired by PR #75 (@MartinNielsenDev).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix five stacking/overflow bugs in static/style.css (no JS changes):
1. Profile dropdown overlaps chat messages
.topbar lacked a stacking context -- added position:relative;z-index:10
so the dropdown (z-index:200 child) always paints above .messages (z-index:0)
2. Workspace dropdown clipped by sidebar overflow:hidden
.sidebar overflow:hidden was swallowing the upward-opening ws-dropdown.
Changed to overflow:visible -- scroll is already on .session-list, not .sidebar.
Added position:relative;z-index:10;overflow:visible to .sidebar-bottom.
3. Slash-command dropdown could render behind tool cards
.composer-wrap had position:relative but no z-index.
Added z-index:10 so cmd-dropdown always sits above .messages (z-index:0).
4. Skill picker dropdown clipped inside Settings modal
.settings-panel had overflow-y:auto which clipped the absolute-positioned
skill picker. Changed to overflow:visible + display:flex;flex-direction:column,
moved overflow-y:auto to .settings-body, raised skill-picker-dropdown to z-index:1100.
5. CLI session badge blocks action buttons on hover
Added .session-item.cli-session:hover::after { display:none } so the gold
'cli' label hides on hover, making archive/delete/pin fully reachable.
6. Workspace dropdown name+path crowded on same line
.ws-opt was a plain block with inline spans. Added flex-direction:column;gap:4px
and display:block to each child so name and path stack cleanly on separate lines.
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
1. Image preview onerror fires on clearPreview (#68)
clearPreview() set previewImg.src='' which triggered the stale onerror
handler, showing 'Could not load image' on every refresh/message.
Fix: null out onerror before clearing src.
2. CLI session badge covers delete button (#69)
The ::after 'cli' label occupied the same space as the hover-revealed
.session-actions overlay, making delete unreachable.
Fix: add padding-right to .cli-session, use margin-left:auto to push
badge right, add pointer-events:none so clicks pass through.
3. Tool cards visible through profile dropdown
The .messages container had no stacking context, so tool cards could
render above the profile dropdown (z-index:200).
Fix: add position:relative;z-index:0 to .messages to establish a
stacking context that keeps all children below overlays.
Closes#68, closes#69
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a server-side boolean setting (default: false) that controls whether
CLI sessions from state.db appear in the sidebar. Off by default so the
sidebar is clean until the user explicitly opts in.
- api/config.py: add show_cli_sessions to _SETTINGS_DEFAULTS and _SETTINGS_BOOL_KEYS
- api/routes.py: gate get_cli_sessions() call on the setting at request time
- static/index.html: checkbox in settings panel with description
- static/panels.js: load/save checkbox, refresh session list on save
- static/boot.js: load on startup alongside send_key and show_token_usage
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
The backend CLI session bridge (PR #56) was complete but the frontend
never connected to it:
1. css class never applied -- el.className never included 'cli-session'
so the gold border and 'cli' badge CSS was dead code. Fixed: append
' cli-session' when s.is_cli_session is true.
2. import never triggered -- click handler always called loadSession()
directly, never POST /api/session/import_cli. Fixed: for CLI sessions,
call import_cli first (idempotent -- safe to call on every click),
then fall through to loadSession() which now finds the imported copy.
3. profile filter silently hid CLI sessions -- filter required
s.profile === S.activeProfile, but CLI sessions may have profile=null
if the SQLite DB has no profile column. Fixed: CLI sessions always
pass the filter (s.is_cli_session || s.profile === S.activeProfile).
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Resolved CHANGELOG.md and SPRINTS.md conflicts: master added v0.29
(Sprint 23: Agentic Transparency), CLI bridge becomes v0.30.
Updated all version references to v0.30.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Read CLI sessions from the agent's state.db and surface them in the
WebUI sidebar alongside local sessions, with read-only display and
import-on-click to avoid data duplication.
Key changes:
- get_cli_sessions(): reads sessions list via parameterized SQL,
wrapped in sqlite3 context manager (no connection leaks)
- get_cli_session_messages(): reads messages for a CLI session
via parameterized SQL, also context-managed
- GET /api/sessions: merges WebUI + CLI sessions with dedup
(WebUI takes priority on same session_id)
- GET /api/session: falls back to CLI store if not a WebUI session
- POST /api/session/import_cli: imports a CLI session into the
WebUI store (idempotent, no duplicates on re-import)
- Imported sessions use get_last_workspace() for the workspace field
(not a hardcoded string) and carry the active profile tag
- CSS: .cli-session with ::after 'cli' indicator (no theme changes)
Fixes review feedback:
- SQLite connections use 'with' context managers (no leaks)
- Workspace uses real path via get_last_workspace()
- Profile awareness via api.profiles.get_active_profile_name()
- Parameterized SQL queries throughout (no injection risk)
- Graceful fallback when sqlite3 or state.db is missing
Token usage display:
- Add 'show_token_usage' boolean to settings (default: false, off by default)
- Settings panel: checkbox 'Show token usage after responses'
- /usage slash command: instant toggle with toast feedback, persists to
server, updates checkbox if settings panel is open, re-renders messages
- Boot: load show_token_usage alongside send_key on startup
- ui.js: gate usage badge on window._showTokenUsage flag
Timestamps:
- streaming.py: stamp 'timestamp' on every message that lacks one at
conversation completion; old messages (no timestamp field) now get a
wall-clock time the first time they're touched by a new turn
- messages.js: stamp _ts on the last assistant message at done-event time
so the time shows immediately on the current turn before next reload
- Timestamps already render in the UI (Sprint 14): faint time on each
role header line, full opacity on hover, full date in title tooltip
Store expanded directory paths in localStorage keyed by workspace path
(key: 'hermes-webui-expanded:{workspacePath}'). On root load (loadDir('.')),
restore the saved set for the current workspace and pre-fetch dir contents
for any restored expanded directories so the tree renders fully on first
paint without requiring a second click to expand.
Saves on every expand/collapse toggle. Switching workspaces automatically
picks up that workspace's own saved state. Per-workspace (not per-session)
so the same tree state is shared across sessions using the same workspace,
which is the natural expectation.
- routes.py: reject glob wildcards (* ? [ ]) in skill name param to
prevent rglob wildcard injection when serving linked files
- panels.js: replace inline onclick+esc() with data-* attributes and
addEventListener for skill tag removal and linked-file clicks;
esc() is HTML-safe but not JS-safe -- apostrophes in names caused
JS syntax errors and _cronSelectedSkills array corruption
- ui.js: fix _fmtTokens(null/undefined) returning 'null'/'undefined'
by guarding with (!n||n<0) -> '0'; add data-role attribute to msg-row
elements so usage badge correctly targets the last assistant row
instead of the last row regardless of speaker
- tests: rename test_sprint24.py -> test_sprint23.py (wrong sprint #);
add 3 new tests: path traversal rejection, wildcard name rejection,
cron create with skills; strengthen existing tests to assert field
presence explicitly (was using .get(field, 0)==0 which never caught
a missing field)
Track A: Token/cost display
- Read agent usage attrs (session_prompt_tokens, session_completion_tokens,
session_estimated_cost_usd) after run_conversation in streaming.py
- Add input_tokens, output_tokens, estimated_cost fields to Session model
- Include usage in done SSE event payload
- Store usage on S.lastUsage in messages.js done handler
- Render usage badge below last assistant message (input/output/cost)
Track B: Subagent delegation cards
- Add subagent_progress to toolIcon map with shuffle emoji
- Special-case subagent_progress in buildToolCard: "Subagent" label,
strip double emoji from preview, add tool-card-subagent CSS class
- Indented border-left styling for subagent cards
- Clean delegate_task display name
Track C: Skill picker in cron create form
- Add skill search input + tag chips to cron create form HTML
- Skill picker JS in panels.js: search/filter, click-to-add tags,
remove tag chips, pre-fetch skill list on form open
- submitCronCreate sends skills array in POST body
- Skill picker dropdown + tag CSS
Track D: Skill linked files viewer
- Add file query param to /api/skills/content endpoint
- Serve linked files from skill directory with path traversal protection
- Ensure linked_files key always present in skill content response
- Render linked files section below SKILL.md content in preview panel
- openSkillFile function for viewing individual linked files
Track E: Bug fixes and code quality
- Expand Session.__init__ and compact() to readable multi-line format
- Remove inline import json as _j2 inside loop in streaming.py
- Fix tool_calls: capture args from assistant messages, skip unresolved names
- Store args snapshot in persisted tool_calls for reload display
6 new tests. Total: 421 (409 passing).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fixes for Camanji rate limit UX:
1. api/streaming.py — pass fallback_model from profile config to AIAgent
The agent already supports fallback_model (a dict with provider/model/base_url)
for automatic rate-limit recovery, but streaming.py never read it from config
or passed it to AIAgent. Now reads get_config().get('fallback_model') at
call time (not module-level snapshot) and passes it through.
Also reads platform_toolsets.cli from the active profile's config at call
time so profiles with custom toolset lists use the right tools.
Camanji has fallback_model: {provider: openrouter, model: anthropic/claude-sonnet-4.6}
so hitting the direct-Anthropic rate limit will now automatically retry via
OpenRouter before giving up.
2. api/streaming.py + static/messages.js — show error inline, not 'Connection lost'
Previously: agent threw -> put('error', msg) -> SSE connection closed ->
browser's network-level 'error' event fired -> generic 'Connection lost'.
The actual error message was invisible to the user.
Fix: renamed server-side error event to 'apperror' (distinct from the SSE
spec's network error event). Added source.addEventListener('apperror', ...)
in messages.js that renders the error as a styled assistant message:
⏱️ Rate limit reached: <full message>
*Rate limit reached. Fallback model exhausted. Try again in a moment.*
Also added source.addEventListener('warning', ...) for non-fatal notices
(future use: fallback-activated status bar update).
Tests: 426 passed, 0 failed.
A session with messages belongs to the profile it was created under. Switching
profiles while a conversation is in progress should not retag that session or
update its workspace/model in place — that would corrupt the session's context.
New behavior:
- Session has NO messages (empty): profile switch updates it in place (model,
workspace). Works exactly as before — nothing was started yet.
- Session HAS messages (in progress): profile switch calls newSession() to
start a fresh session tagged to the new profile. The old session is left
untouched. Toast: 'Switched to profile: X — new conversation started'.
- Agent busy: blocked as before, no change.
Also: S._profileDefaultWorkspace is now consumed (set to null) inside
newSession() after the first use, so it doesn't keep forcing the same
workspace on every subsequent new session after a switch.
Root cause: three interacting bugs caused the model picker to show the wrong
model or flicker after a profile switch.
Bug 1 — syncTopbar() fought switchToProfile().
After switchToProfile() set the picker to the profile's model, syncTopbar()
was called (via renderSessionList -> loadSession, then explicitly at the end)
and overwrote it with S.session.model -- the old session's model.
Fix: added S._pendingProfileModel flag. switchToProfile() sets it;
syncTopbar() checks it first, applies the override, then clears it.
S.session.model is also updated to the resolved value so subsequent
syncTopbar() calls are consistent.
Bug 2 — Raw option injected at top of list for mismatched model IDs.
Profile configs store model IDs like 'claude-sonnet-4-6' (hermes-agent
format: hyphens, no namespace prefix) but the dropdown has
'anthropic/claude-sonnet-4.6' (OpenRouter format: dots, with prefix).
The old code did sel.value = id, found no match, then injected a new
<option> at the top of the list -- creating a lowercase duplicate that
didn't match any real provider group entry.
Fix: _findModelInDropdown() normalises both sides (strip prefix, hyphens->dots,
lowercase) and finds the best matching existing option. No new options are ever
injected for profile switching.
Bug 3 — populateModelDropdown() injected raw option on cold load.
Same issue: if default_model from /api/models didn't exactly match a dropdown
value, an extra option was added. Fixed by using _applyModelToDropdown()
which only selects existing options.
New helpers in ui.js:
_findModelInDropdown(modelId, sel) -- smart fuzzy match, returns matched value
_applyModelToDropdown(modelId, sel) -- sets picker, returns resolved value
Tests: 426 passed, 0 failed.
Root cause: sessions created before Sprint 22 have no profile tag (profile=None).
The client filter was '!s.profile || s.profile === S.activeProfile' -- the
'!s.profile' guard made ALL 33 legacy sessions visible under every profile,
so switching to Camanji still showed the entire default session history.
Fix:
- api/models.py all_sessions(): backfill profile='default' on sessions with
no profile tag before returning. This is in-memory only (no disk writes) --
legacy sessions just get attributed to the default profile at read time.
Applied to both the index-path and the full-scan fallback path.
- static/sessions.js: tighten the client filter to s.profile === S.activeProfile
(remove the '!s.profile' escape hatch -- now redundant since server fills it).
Every session now has an explicit profile, so the filter is precise.
Result: switching to Camanji shows only Camanji sessions. Default profile shows
legacy + default-tagged sessions. 'All profiles' toggle still shows everything.
S.activeProfile defaults to 'default' in the S object so first render is safe.
Tests: 426 passed, 0 failed.
1. _profile_default_workspace() now checks terminal.cwd
Profile config.yaml files don't have a 'workspace' or 'default_workspace' key
— they store the working directory as terminal.cwd (the hermes-agent CLI
setting). Added it as the third fallback after 'workspace' and
'default_workspace', so switching to camanji correctly resolves
~/Camanji, webui resolves ~/webui-mvp, etc.
2. Workspace dropdown opens upward (bottom: calc(100% + 4px))
The dropdown is now anchored at the bottom of the sidebar. Opening it
downward (top: 100%) caused it to clip off screen. Flipped to open upward
with an upward shadow so it expands into the session list area instead.
Tests: 426 passed, 0 failed.
Two changes:
1. Workspace updates correctly on profile switch
switchToProfile() now applies data.default_workspace from the switch
response to the current session via /api/session/update, updates
S.session.workspace in-memory, and stores S._profileDefaultWorkspace
so the next new session also inherits the profile's workspace.
newSession() in sessions.js picks up S._profileDefaultWorkspace when
creating a new session after a profile switch.
2. Workspace chip removed from topbar
The workspace was shown in two places: the topbar chip (wsChip) AND
the sidebar bottom display (sidebarWsDisplay with name + full path).
The topbar chip was redundant, cluttered the topbar, and pushed other
chips (profile, model, clear, settings) off screen.
Removed wsChip from the topbar entirely. The sidebar display is now
the sole workspace UI, consistent and unambiguous.
Moved wsDropdown to live inside the sidebar position:relative wrapper
so it opens downward from sidebarWsDisplay. Updated the click-outside
listener to close on clicks outside sidebarWsDisplay/wsDropdown.
Removed stale wsChip update code from syncTopbar() in ui.js.
Tests: 426 passed, 0 failed.
Root cause: _DEFAULT_HERMES_HOME was evaluated at module import time from
os.getenv('HERMES_HOME'). HERMES_HOME is a MUTABLE env var -- init_profile_state()
at server startup calls _set_hermes_home() which writes to os.environ['HERMES_HOME'].
If the sticky active_profile file pointed to e.g. 'webui', HERMES_HOME was set to
~/.hermes/profiles/webui BEFORE api/profiles.py imported. So _DEFAULT_HERMES_HOME
resolved to ~/.hermes/profiles/webui. Then switch_profile('webui') computed:
home = ~/.hermes/profiles/webui / 'profiles' / 'webui'
= ~/.hermes/profiles/webui/profiles/webui -- doesn't exist -> 404 ValueError
Fix: replace the one-liner assignment with _resolve_base_hermes_home() which:
1. Checks HERMES_BASE_HOME env var (explicit override)
2. Checks HERMES_HOME -- but if it looks like a profiles/ subdir (parent.name ==
'profiles'), walks up two levels to the actual base
3. Falls back to Path.home() / '.hermes'
This means the server can start with HERMES_HOME pointing to any profile and
_DEFAULT_HERMES_HOME will still correctly point to ~/.hermes.
Also fix: api() helper in workspace.js was throwing new Error(await res.text())
which surfaced raw JSON to the UI: 'Switch failed: {"error":"Profile X does not exist."}'
Now parses the JSON and extracts j.error so the toast shows clean human-readable text.
Regression tests added in test_sprint23.py:
- test_profile_switch_base_home_not_subdir: static analysis verifying the resolver
- test_api_helper_returns_clean_error_message: verifies api() parses JSON errors
- test_profile_switch_resolve_base_home_logic: verifies the profiles/ subdir detection
Tests: 426 passed, 0 failed.
Fix five coherence bugs in profile switching:
1. Model picker ignored profile default (localStorage stale key)
2. Workspace list was global (not profile-scoped)
3. DEFAULT_WORKSPACE was a boot-time singleton
4. Session list showed all profiles (no filtering)
5. switchToProfile() didn't refresh workspaces or sessions
Backend: workspace storage is now profile-local for named profiles,
switch_profile() returns default_model and default_workspace.
Frontend: switchToProfile() clears stale model pref, refreshes
workspace list and session list, sessions.js filters by active profile
with 'Show N from other profiles' toggle.
8 new tests. 400 pass / 23 fail (identical to baseline).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BUG-3 (high): /api/profile/delete missing RuntimeError catch. When
deleting the active profile while an agent was running, delete_profile_api()
called switch_profile('default') which raises RuntimeError('Cannot switch
profiles while agent is running'). This propagated to the 500 handler
giving the user 'Internal server error' with no context. Added the same
except RuntimeError -> 409 pattern that /api/profile/switch already uses.
INFO-1 (defense-in-depth): /api/profile/create had no server-side name
validation before delegating to hermes_cli.validate_profile_name. Added
server-side ^[a-z0-9][a-z0-9_-]{0,63}$ check, consistent with client-side
regex in submitProfileCreate(). Prevents path-traversal-ish names from
reaching hermes_cli even if the client-side guard is bypassed.
INFO-2 (defense-in-depth): clone_from parameter was passed directly to
hermes_cli with no validation. Applied the same name regex check to
clone_from before delegating.
BUG-11 (low): toggleProfileDropdown() and toggleWsDropdown() could both
be open simultaneously. Added cross-dropdown close calls: opening the
profile dropdown now closes the workspace dropdown, and vice versa.
Tests: 415 passed, 0 failed.
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BUG-1 (critical): CSS cascade — .sidebar{position:relative} and
.rightpanel{position:relative} at line 528/530 appeared after the
@media(max-width:640px) block and silently overrode the position:fixed
overlay behavior needed for the mobile slide-in. Wrapped both in
@media(min-width:641px) so they only apply on desktop.
BUG-2 (medium): mobileSwitchPanel() in boot.js always reopened the
sidebar overlay after closing it, with a stale comment saying 'close
after a moment' but no actual auto-close. For the 'chat' panel, the
content lives in the main area — reopening the sidebar obstructs it.
Fixed: only open sidebar for non-chat panels; chat tap closes sidebar.
BUG-3 (medium): Dockerfile was missing 'pip install -r requirements.txt'.
pyyaml (required by api/config.py) is not in the python:3.12-slim base
image — the container would fail at startup with ImportError.
SEC-2 (medium): No .dockerignore — COPY . /app included .git/, tests/,
and .env* in every image. Added .dockerignore excluding these.
NIT-3: docker-compose.yml used ${HERMES_HOME:-~/.hermes} but Docker
Compose does not shell-expand ~ in default values. Changed to
${HERMES_HOME:-${HOME}/.hermes}.
Tests: 415 passed, 0 failed (same as pre-fix).
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>
- index.html: btnSend hidden by default (display:none), icon-only (upward
arrow SVG, no text label), title attribute for accessibility
- style.css: new send-btn design — 34px circle, blue fill (#7cb9ff),
subtle glow box-shadow, scale() hover/active for tactile feel,
.send-btn.visible with @keyframes send-pop-in (scale+opacity spring
using cubic-bezier(.34,1.56,.64,1) for a satisfying pop). Mobile
override updated to preserve circle dimensions.
- ui.js: updateSendBtn() — shows button with pop-in animation when
textarea has content OR files are attached and agent is not busy;
hides instantly when content is cleared. Hooked into setBusy() and
renderTray() so button state tracks all content sources correctly.
- boot.js: input event listener calls updateSendBtn() on every keystroke.
- messages.js: autoResize() calls updateSendBtn() so button disappears
immediately after send clears the textarea.
- tests/test_sprint21.py: 33 tests covering HTML structure, CSS design
(circle shape, colors, animations, keyframes), JS logic (updateSendBtn,
setBusy, renderTray, autoResize integration), and regressions
(363 total, all pass).
Previously, tapping the mic button would reset the textarea each time,
clobbering anything the user had already typed or previously dictated.
Fix:
- Capture _prefix = ta.value when recording starts (btn.onclick)
- onresult writes _prefix + (final || interim) so live interim text
appears after the existing content, not replacing it
- onend commits _prefix + _finalText with smart space insertion:
if the prefix doesn't end with a space or newline, a space is added
before the new transcript so words don't run together
- _prefix is reset to '' in _setRecording(false) so each new recording
session starts with a fresh snapshot
Behaviour now: tap mic, speak, tap again (or wait for auto-stop) ->
transcript is appended to whatever was in the textarea. Tap mic again
-> continues appending further. Text stays fully editable before send.
tests/test_sprint20.py: 6 new tests covering prefix capture, onresult
prepend, onend commit, reset, and smart spacing (52 total, 382 overall).
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).