2026-04-02 18:08:34 +00:00
// ── Session action icons (SVG, monochrome, inherit currentColor) ──
const ICONS = {
pin : '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><polygon points="8,1.5 9.8,5.8 14.5,6.2 11,9.4 12,14 8,11.5 4,14 5,9.4 1.5,6.2 6.2,5.8"/></svg>' ,
unpin : '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><polygon points="8,2 9.8,6.2 14.2,6.2 10.7,9.2 12,13.8 8,11 4,13.8 5.3,9.2 1.8,6.2 6.2,6.2"/></svg>' ,
folder : '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M2 4.5h4l1.5 1.5H14v7H2z"/></svg>' ,
archive : '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><line x1="6" y1="8.5" x2="10" y2="8.5"/></svg>' ,
unarchive : '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><polyline points="6.5,7 8,5.5 9.5,7"/></svg>' ,
dup : '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="4.5" y="4.5" width="8.5" height="8.5" rx="1.5"/><path d="M3 11.5V3h8.5"/></svg>' ,
trash : '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>' ,
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
more : '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>' ,
2026-04-02 18:08:34 +00:00
} ;
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
let _sessionActionMenu = null ;
let _sessionActionAnchor = null ;
let _sessionActionSessionId = null ;
function closeSessionActionMenu ( ) {
if ( _sessionActionMenu ) {
_sessionActionMenu . remove ( ) ;
_sessionActionMenu = null ;
}
if ( _sessionActionAnchor ) {
_sessionActionAnchor . classList . remove ( 'active' ) ;
const row = _sessionActionAnchor . closest ( '.session-item' ) ;
if ( row ) row . classList . remove ( 'menu-open' ) ;
_sessionActionAnchor = null ;
}
_sessionActionSessionId = null ;
}
function _positionSessionActionMenu ( anchorEl ) {
if ( ! _sessionActionMenu || ! anchorEl ) return ;
const rect = anchorEl . getBoundingClientRect ( ) ;
const menuW = Math . min ( 280 , Math . max ( 220 , _sessionActionMenu . scrollWidth || 220 ) ) ;
let left = rect . right - menuW ;
if ( left < 8 ) left = 8 ;
if ( left + menuW > window . innerWidth - 8 ) left = window . innerWidth - menuW - 8 ;
_sessionActionMenu . style . left = left + 'px' ;
_sessionActionMenu . style . top = '8px' ;
const menuH = _sessionActionMenu . offsetHeight || 0 ;
let top = rect . bottom + 6 ;
if ( top + menuH > window . innerHeight - 8 && rect . top > menuH + 12 ) {
top = rect . top - menuH - 6 ;
}
if ( top < 8 ) top = 8 ;
_sessionActionMenu . style . top = top + 'px' ;
}
function _buildSessionAction ( label , meta , icon , onSelect , extraClass = '' ) {
const opt = document . createElement ( 'button' ) ;
opt . type = 'button' ;
opt . className = 'ws-opt session-action-opt' + ( extraClass ? ` ${ extraClass } ` : '' ) ;
opt . innerHTML =
` <span class="ws-opt-action"> `
+ ` <span class="ws-opt-icon"> ${ icon } </span> `
+ ` <span class="session-action-copy"> `
+ ` <span class="ws-opt-name"> ${ esc ( label ) } </span> `
+ ( meta ? ` <span class="session-action-meta"> ${ esc ( meta ) } </span> ` : '' )
+ ` </span> `
+ ` </span> ` ;
opt . onclick = async ( e ) => {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
await onSelect ( ) ;
} ;
return opt ;
}
function _openSessionActionMenu ( session , anchorEl ) {
if ( _sessionActionMenu && _sessionActionSessionId === session . session _id && _sessionActionAnchor === anchorEl ) {
closeSessionActionMenu ( ) ;
return ;
}
closeSessionActionMenu ( ) ;
const menu = document . createElement ( 'div' ) ;
menu . className = 'session-action-menu open' ;
menu . appendChild ( _buildSessionAction (
session . pinned ? 'Unpin conversation' : 'Pin conversation' ,
session . pinned ? 'Remove from the pinned section' : 'Keep this conversation at the top' ,
session . pinned ? ICONS . pin : ICONS . unpin ,
async ( ) => {
closeSessionActionMenu ( ) ;
const newPinned = ! session . pinned ;
try {
await api ( '/api/session/pin' , { method : 'POST' , body : JSON . stringify ( { session _id : session . session _id , pinned : newPinned } ) } ) ;
session . pinned = newPinned ;
if ( S . session && S . session . session _id === session . session _id ) S . session . pinned = newPinned ;
renderSessionList ( ) ;
} catch ( err ) { showToast ( 'Pin failed: ' + err . message ) ; }
} ,
session . pinned ? 'is-active' : ''
) ) ;
menu . appendChild ( _buildSessionAction (
'Move to project' ,
session . project _id ? 'Change which project this conversation belongs to' : 'Assign this conversation to a project' ,
ICONS . folder ,
async ( ) => {
closeSessionActionMenu ( ) ;
_showProjectPicker ( session , anchorEl ) ;
}
) ) ;
menu . appendChild ( _buildSessionAction (
session . archived ? 'Restore conversation' : 'Archive conversation' ,
session . archived ? 'Bring this conversation back into the main list' : 'Hide this conversation until archived is shown' ,
session . archived ? ICONS . unarchive : ICONS . archive ,
async ( ) => {
closeSessionActionMenu ( ) ;
try {
await api ( '/api/session/archive' , { method : 'POST' , body : JSON . stringify ( { session _id : session . session _id , archived : ! session . archived } ) } ) ;
session . archived = ! session . archived ;
if ( S . session && S . session . session _id === session . session _id ) S . session . archived = session . archived ;
await renderSessionList ( ) ;
showToast ( session . archived ? 'Session archived' : 'Session restored' ) ;
} catch ( err ) { showToast ( 'Archive failed: ' + err . message ) ; }
}
) ) ;
menu . appendChild ( _buildSessionAction (
'Duplicate conversation' ,
'Create a copy with the same workspace and model' ,
ICONS . dup ,
async ( ) => {
closeSessionActionMenu ( ) ;
try {
const res = await api ( '/api/session/new' , { method : 'POST' , body : JSON . stringify ( { workspace : session . workspace , model : session . model } ) } ) ;
if ( res . session ) {
await api ( '/api/session/rename' , { method : 'POST' , body : JSON . stringify ( { session _id : res . session . session _id , title : ( session . title || 'Untitled' ) + ' (copy)' } ) } ) ;
await loadSession ( res . session . session _id ) ;
await renderSessionList ( ) ;
showToast ( 'Session duplicated' ) ;
}
} catch ( err ) { showToast ( 'Duplicate failed: ' + err . message ) ; }
}
) ) ;
menu . appendChild ( _buildSessionAction (
'Delete conversation' ,
'Permanently remove this conversation' ,
ICONS . trash ,
async ( ) => {
closeSessionActionMenu ( ) ;
await deleteSession ( session . session _id ) ;
} ,
'danger'
) ) ;
document . body . appendChild ( menu ) ;
_sessionActionMenu = menu ;
_sessionActionAnchor = anchorEl ;
_sessionActionSessionId = session . session _id ;
anchorEl . classList . add ( 'active' ) ;
const row = anchorEl . closest ( '.session-item' ) ;
if ( row ) row . classList . add ( 'menu-open' ) ;
_positionSessionActionMenu ( anchorEl ) ;
}
document . addEventListener ( 'click' , e => {
if ( ! _sessionActionMenu ) return ;
if ( _sessionActionMenu . contains ( e . target ) ) return ;
if ( _sessionActionAnchor && _sessionActionAnchor . contains ( e . target ) ) return ;
closeSessionActionMenu ( ) ;
} ) ;
document . addEventListener ( 'scroll' , e => {
if ( ! _sessionActionMenu ) return ;
if ( _sessionActionMenu . contains ( e . target ) ) return ;
closeSessionActionMenu ( ) ;
} , true ) ;
document . addEventListener ( 'keydown' , e => {
if ( e . key === 'Escape' && _sessionActionMenu ) closeSessionActionMenu ( ) ;
} ) ;
window . addEventListener ( 'resize' , ( ) => {
if ( _sessionActionMenu && _sessionActionAnchor ) _positionSessionActionMenu ( _sessionActionAnchor ) ;
} ) ;
2026-03-31 03:40:19 +00:00
async function newSession ( flash ) {
MSG _QUEUE . length = 0 ; updateQueueBadge ( ) ;
S . toolCalls = [ ] ;
clearLiveToolCards ( ) ;
2026-04-03 20:27:50 +00:00
// Use profile default workspace for new sessions after a profile switch (one-shot),
2026-04-03 19:38:33 +00:00
// otherwise inherit from the current session (or let server pick the default)
2026-04-03 20:27:50 +00:00
const inheritWs = S . _profileDefaultWorkspace || ( S . session ? S . session . workspace : null ) ;
S . _profileDefaultWorkspace = null ; // consume — only applies to the first new session after switch
2026-03-31 03:40:19 +00:00
const data = await api ( '/api/session/new' , { method : 'POST' , body : JSON . stringify ( { model : $ ( 'modelSelect' ) . value , workspace : inheritWs } ) } ) ;
S . session = data . session ; S . messages = data . session . messages || [ ] ;
if ( flash ) S . session . _flash = true ;
localStorage . setItem ( 'hermes-webui-session' , S . session . session _id ) ;
syncTopbar ( ) ; await loadDir ( '.' ) ; renderMessages ( ) ;
// don't call renderSessionList here - callers do it when needed
}
async function loadSession ( sid ) {
stopApprovalPolling ( ) ; hideApprovalCard ( ) ;
const data = await api ( ` /api/session?session_id= ${ encodeURIComponent ( sid ) } ` ) ;
S . session = data . session ;
localStorage . setItem ( 'hermes-webui-session' , S . session . session _id ) ;
// B9: sanitize empty assistant messages that can appear when agent only ran tool calls
data . session . messages = ( data . session . messages || [ ] ) . filter ( m => {
if ( ! m || ! m . role ) return false ;
if ( m . role === 'tool' ) return false ;
if ( m . role === 'assistant' ) { let c = m . content || '' ; if ( Array . isArray ( c ) ) c = c . filter ( p => p && p . type === 'text' ) . map ( p => p . text || '' ) . join ( '' ) ; return String ( c ) . trim ( ) . length > 0 ; }
return true ;
} ) ;
if ( INFLIGHT [ sid ] ) {
S . messages = INFLIGHT [ sid ] . messages ;
// Restore live tool cards for this in-flight session
clearLiveToolCards ( ) ;
for ( const tc of ( S . toolCalls || [ ] ) ) {
if ( tc && tc . name ) appendLiveToolCard ( tc ) ;
}
syncTopbar ( ) ; await loadDir ( '.' ) ; renderMessages ( ) ; appendThinking ( ) ;
2026-04-06 05:10:59 +00:00
setBusy ( true ) ; setStatus ( ( window . _botName || 'Hermes' ) + ' is thinking\u2026' ) ;
2026-03-31 03:40:19 +00:00
startApprovalPolling ( sid ) ;
} else {
MSG _QUEUE . length = 0 ; updateQueueBadge ( ) ; // clear queue for the viewed session
S . messages = data . session . messages || [ ] ;
S . toolCalls = ( data . session . tool _calls || [ ] ) . map ( tc => ( { ... tc , done : true } ) ) ;
// Reset per-session visual state: the viewed session is idle even if another
// session's stream is still running in the background.
// We directly update the DOM instead of calling setBusy(false), because
// setBusy(false) drains MSG_QUEUE which we don't want here.
S . busy = false ;
S . activeStreamId = null ;
$ ( 'btnSend' ) . disabled = false ;
$ ( 'btnSend' ) . style . opacity = '1' ;
const _dots = $ ( 'activityDots' ) ; if ( _dots ) _dots . style . display = 'none' ;
const _cb = $ ( 'btnCancel' ) ; if ( _cb ) _cb . style . display = 'none' ;
setStatus ( '' ) ;
clearLiveToolCards ( ) ;
syncTopbar ( ) ; await loadDir ( '.' ) ; renderMessages ( ) ; highlightCode ( ) ;
}
}
let _allSessions = [ ] ; // cached for search filter
let _renamingSid = null ; // session_id currently being renamed (blocks list re-renders)
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
let _showArchived = false ; // toggle to show archived sessions
2026-04-02 06:55:21 +00:00
let _allProjects = [ ] ; // cached project list
let _activeProject = null ; // project_id filter (null = show all)
2026-04-03 18:46:15 +00:00
let _showAllProfiles = false ; // false = filter to active profile only
2026-03-31 03:40:19 +00:00
async function renderSessionList ( ) {
try {
if ( ! ( $ ( 'sessionSearch' ) . value || '' ) . trim ( ) ) _contentSearchResults = [ ] ;
2026-04-02 06:55:21 +00:00
const [ sessData , projData ] = await Promise . all ( [
api ( '/api/sessions' ) ,
api ( '/api/projects' ) ,
] ) ;
_allSessions = sessData . sessions || [ ] ;
_allProjects = projData . projects || [ ] ;
2026-03-31 03:40:19 +00:00
renderSessionListFromCache ( ) ; // no-ops if rename is in progress
} catch ( e ) { console . warn ( 'renderSessionList' , e ) ; }
}
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
// ── Gateway session SSE (real-time sync for agent sessions) ──
let _gatewaySSE = null ;
function startGatewaySSE ( ) {
stopGatewaySSE ( ) ;
if ( ! window . _showCliSessions ) return ;
try {
_gatewaySSE = new EventSource ( '/api/sessions/gateway/stream' ) ;
_gatewaySSE . addEventListener ( 'sessions_changed' , ( ev ) => {
try {
const data = JSON . parse ( ev . data ) ;
if ( data . sessions ) {
renderSessionList ( ) ; // re-fetch and re-render
}
} catch ( e ) { /* ignore parse errors */ }
} ) ;
_gatewaySSE . onerror = ( ) => {
// EventSource auto-reconnects; no action needed
} ;
} catch ( e ) { /* SSE not available */ }
}
function stopGatewaySSE ( ) {
if ( _gatewaySSE ) {
_gatewaySSE . close ( ) ;
_gatewaySSE = null ;
}
}
2026-03-31 03:40:19 +00:00
let _searchDebounceTimer = null ;
let _contentSearchResults = [ ] ; // results from /api/sessions/search content scan
function filterSessions ( ) {
// Immediate client-side title filter (no flicker)
renderSessionListFromCache ( ) ;
// Debounced content search via API for message text
const q = ( $ ( 'sessionSearch' ) . value || '' ) . trim ( ) ;
clearTimeout ( _searchDebounceTimer ) ;
if ( ! q ) { _contentSearchResults = [ ] ; return ; }
_searchDebounceTimer = setTimeout ( async ( ) => {
try {
const data = await api ( ` /api/sessions/search?q= ${ encodeURIComponent ( q ) } &content=1&depth=5 ` ) ;
const titleIds = new Set ( _allSessions . filter ( s => ( s . title || 'Untitled' ) . toLowerCase ( ) . includes ( q . toLowerCase ( ) ) ) . map ( s => s . session _id ) ) ;
_contentSearchResults = ( data . sessions || [ ] ) . filter ( s => s . match _type === 'content' && ! titleIds . has ( s . session _id ) ) ;
renderSessionListFromCache ( ) ;
} catch ( e ) { /* ignore */ }
} , 350 ) ;
}
function renderSessionListFromCache ( ) {
// Don't re-render while user is actively renaming a session (would destroy the input)
if ( _renamingSid ) return ;
const q = ( $ ( 'sessionSearch' ) . value || '' ) . toLowerCase ( ) ;
const titleMatches = q ? _allSessions . filter ( s => ( s . title || 'Untitled' ) . toLowerCase ( ) . includes ( q ) ) : _allSessions ;
// Merge content matches (deduped): content matches appended after title matches
const titleIds = new Set ( titleMatches . map ( s => s . session _id ) ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
const allMatched = q ? [ ... titleMatches , ... _contentSearchResults . filter ( s => ! titleIds . has ( s . session _id ) ) ] : titleMatches ;
2026-04-03 18:46:15 +00:00
// Filter by active profile (unless "All profiles" is toggled on)
2026-04-03 19:50:08 +00:00
// Server backfills profile='default' for legacy sessions, so every session has a profile.
// Show only sessions tagged to the active profile; 'All profiles' toggle overrides.
2026-04-04 03:55:29 +00:00
const profileFiltered = _showAllProfiles ? allMatched : allMatched . filter ( s => s . is _cli _session || s . profile === S . activeProfile ) ;
2026-04-02 06:55:21 +00:00
// Filter by active project
2026-04-03 18:46:15 +00:00
const projectFiltered = _activeProject ? profileFiltered . filter ( s => s . project _id === _activeProject ) : profileFiltered ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
// Filter archived unless toggle is on
2026-04-02 06:55:21 +00:00
const sessions = _showArchived ? projectFiltered : projectFiltered . filter ( s => ! s . archived ) ;
const archivedCount = projectFiltered . filter ( s => s . archived ) . length ;
2026-03-31 03:40:19 +00:00
const list = $ ( 'sessionList' ) ; list . innerHTML = '' ;
2026-04-02 06:55:21 +00:00
// Project filter bar (only when projects exist)
if ( _allProjects . length > 0 ) {
const bar = document . createElement ( 'div' ) ;
bar . className = 'project-bar' ;
// "All" chip
const allChip = document . createElement ( 'span' ) ;
allChip . className = 'project-chip' + ( ! _activeProject ? ' active' : '' ) ;
allChip . textContent = 'All' ;
allChip . onclick = ( ) => { _activeProject = null ; renderSessionListFromCache ( ) ; } ;
bar . appendChild ( allChip ) ;
// Project chips
for ( const p of _allProjects ) {
const chip = document . createElement ( 'span' ) ;
chip . className = 'project-chip' + ( p . project _id === _activeProject ? ' active' : '' ) ;
if ( p . color ) {
const dot = document . createElement ( 'span' ) ;
dot . className = 'color-dot' ;
dot . style . background = p . color ;
chip . appendChild ( dot ) ;
}
const nameSpan = document . createElement ( 'span' ) ;
nameSpan . textContent = p . name ;
chip . appendChild ( nameSpan ) ;
chip . onclick = ( ) => { _activeProject = p . project _id ; renderSessionListFromCache ( ) ; } ;
chip . ondblclick = ( e ) => { e . stopPropagation ( ) ; _startProjectRename ( p , chip ) ; } ;
chip . oncontextmenu = ( e ) => { e . preventDefault ( ) ; _confirmDeleteProject ( p ) ; } ;
bar . appendChild ( chip ) ;
}
// Create button
const addBtn = document . createElement ( 'button' ) ;
addBtn . className = 'project-create-btn' ;
addBtn . textContent = '+' ;
addBtn . title = 'New project' ;
addBtn . onclick = ( e ) => { e . stopPropagation ( ) ; _startProjectCreate ( bar , addBtn ) ; } ;
bar . appendChild ( addBtn ) ;
list . appendChild ( bar ) ;
}
2026-04-03 18:46:15 +00:00
// Profile filter toggle (show sessions from other profiles)
const otherProfileCount = allMatched . filter ( s => s . profile && s . profile !== S . activeProfile ) . length ;
if ( otherProfileCount > 0 && ! _showAllProfiles ) {
const pfToggle = document . createElement ( 'div' ) ;
pfToggle . style . cssText = 'font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;' ;
pfToggle . textContent = 'Show ' + otherProfileCount + ' from other profiles' ;
pfToggle . onclick = ( ) => { _showAllProfiles = true ; renderSessionListFromCache ( ) ; } ;
list . appendChild ( pfToggle ) ;
} else if ( _showAllProfiles && otherProfileCount > 0 ) {
const pfToggle = document . createElement ( 'div' ) ;
pfToggle . style . cssText = 'font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;' ;
pfToggle . textContent = 'Show active profile only' ;
pfToggle . onclick = ( ) => { _showAllProfiles = false ; renderSessionListFromCache ( ) ; } ;
list . appendChild ( pfToggle ) ;
}
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
// Show/hide archived toggle if there are archived sessions
if ( archivedCount > 0 ) {
const toggle = document . createElement ( 'div' ) ;
toggle . style . cssText = 'font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;' ;
toggle . textContent = _showArchived ? 'Hide archived' : 'Show ' + archivedCount + ' archived' ;
toggle . onclick = ( ) => { _showArchived = ! _showArchived ; renderSessionListFromCache ( ) ; } ;
list . appendChild ( toggle ) ;
}
2026-04-02 06:55:21 +00:00
// Empty state for active project filter
if ( _activeProject && sessions . length === 0 ) {
const empty = document . createElement ( 'div' ) ;
empty . style . cssText = 'padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;' ;
empty . textContent = 'No sessions in this project yet.' ;
list . appendChild ( empty ) ;
}
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
// Separate pinned from unpinned
const pinned = sessions . filter ( s => s . pinned ) ;
const unpinned = sessions . filter ( s => ! s . pinned ) ;
// Date grouping: Pinned / Today / Yesterday / Earlier
2026-03-31 03:40:19 +00:00
const now = Date . now ( ) ;
const ONE _DAY = 86400000 ;
2026-04-04 21:05:00 +00:00
// Collapse state persisted in localStorage
let _groupCollapsed = { } ;
try { _groupCollapsed = JSON . parse ( localStorage . getItem ( 'hermes-date-groups-collapsed' ) || '{}' ) ; } catch ( e ) { }
const _saveCollapsed = ( ) => { try { localStorage . setItem ( 'hermes-date-groups-collapsed' , JSON . stringify ( _groupCollapsed ) ) ; } catch ( e ) { } } ;
// Group sessions by date
const groups = [ ] ;
let curLabel = null , curItems = [ ] ;
if ( pinned . length ) groups . push ( { label : '\u2605 Pinned' , items : pinned , isPinned : true } ) ;
for ( const s of unpinned ) {
const ts = ( s . updated _at || s . created _at || 0 ) * 1000 ;
const label = ts > now - ONE _DAY ? 'Today' : ts > now - 2 * ONE _DAY ? 'Yesterday' : 'Earlier' ;
if ( label !== curLabel ) {
if ( curItems . length ) groups . push ( { label : curLabel , items : curItems } ) ;
curLabel = label ; curItems = [ s ] ;
} else { curItems . push ( s ) ; }
}
if ( curItems . length ) groups . push ( { label : curLabel , items : curItems } ) ;
// Render groups with collapsible headers
for ( const g of groups ) {
const wrapper = document . createElement ( 'div' ) ;
wrapper . className = 'session-date-group' ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
const hdr = document . createElement ( 'div' ) ;
2026-04-04 21:05:00 +00:00
hdr . className = 'session-date-header' + ( g . isPinned ? ' pinned' : '' ) ;
const caret = document . createElement ( 'span' ) ;
caret . className = 'session-date-caret' ;
caret . textContent = '\u25B8' ; // right-pointing triangle
const label = document . createElement ( 'span' ) ;
label . textContent = g . label ;
hdr . appendChild ( caret ) ; hdr . appendChild ( label ) ;
const body = document . createElement ( 'div' ) ;
body . className = 'session-date-body' ;
if ( _groupCollapsed [ g . label ] ) { body . style . display = 'none' ; caret . classList . add ( 'collapsed' ) ; }
hdr . onclick = ( ) => {
const isCollapsed = body . style . display === 'none' ;
body . style . display = isCollapsed ? '' : 'none' ;
caret . classList . toggle ( 'collapsed' , ! isCollapsed ) ;
_groupCollapsed [ g . label ] = ! isCollapsed ;
_saveCollapsed ( ) ;
} ;
wrapper . appendChild ( hdr ) ;
for ( const s of g . items ) { body . appendChild ( _renderOneSession ( s ) ) ; }
wrapper . appendChild ( body ) ;
list . appendChild ( wrapper ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
}
2026-04-04 21:05:00 +00:00
// ── Render session items (extracted for group body use) ──
2026-04-04 21:24:04 +00:00
// Note: declared after the groups loop but available via function hoisting.
2026-04-04 21:05:00 +00:00
function _renderOneSession ( s ) {
2026-03-31 03:40:19 +00:00
const el = document . createElement ( 'div' ) ;
const isActive = S . session && s . session _id === S . session . session _id ;
2026-04-04 03:55:29 +00:00
el . className = 'session-item' + ( isActive ? ' active' : '' ) + ( isActive && S . session && S . session . _flash ? ' new-flash' : '' ) + ( s . archived ? ' archived' : '' ) + ( s . is _cli _session ? ' cli-session' : '' ) ;
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
if ( s . source _tag ) el . dataset . source = s . source _tag ;
2026-03-31 03:40:19 +00:00
if ( isActive && S . session && S . session . _flash ) delete S . session . _flash ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
const rawTitle = s . title || 'Untitled' ;
const tags = ( rawTitle . match ( /#[\w-]+/g ) || [ ] ) ;
const cleanTitle = tags . length ? rawTitle . replace ( /#[\w-]+/g , '' ) . trim ( ) : rawTitle ;
2026-03-31 03:40:19 +00:00
const title = document . createElement ( 'span' ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
title . className = 'session-title' ;
title . textContent = cleanTitle || 'Untitled' ;
2026-03-31 03:40:19 +00:00
title . title = 'Double-click to rename' ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
// Append tag chips after the title text
for ( const tag of tags ) {
const chip = document . createElement ( 'span' ) ;
chip . className = 'session-tag' ;
chip . textContent = tag ;
chip . title = 'Click to filter by ' + tag ;
chip . onclick = ( e ) => {
e . stopPropagation ( ) ;
const searchBox = $ ( 'sessionSearch' ) ;
if ( searchBox ) { searchBox . value = tag ; filterSessions ( ) ; }
} ;
title . appendChild ( chip ) ;
}
2026-03-31 03:40:19 +00:00
// Rename: called directly when we confirm it's a double-click
const startRename = ( ) => {
_renamingSid = s . session _id ;
const inp = document . createElement ( 'input' ) ;
inp . className = 'session-title-input' ;
inp . value = s . title || 'Untitled' ;
[ 'click' , 'mousedown' , 'dblclick' , 'pointerdown' ] . forEach ( ev =>
inp . addEventListener ( ev , e2 => e2 . stopPropagation ( ) )
) ;
const finish = async ( save ) => {
_renamingSid = null ;
if ( save ) {
const newTitle = inp . value . trim ( ) || 'Untitled' ;
title . textContent = newTitle ;
s . title = newTitle ;
if ( S . session && S . session . session _id === s . session _id ) { S . session . title = newTitle ; syncTopbar ( ) ; }
try { await api ( '/api/session/rename' , { method : 'POST' , body : JSON . stringify ( { session _id : s . session _id , title : newTitle } ) } ) ; }
catch ( err ) { setStatus ( 'Rename failed: ' + err . message ) ; }
}
inp . replaceWith ( title ) ;
// Allow list re-renders again after a short delay
setTimeout ( ( ) => { if ( _renamingSid === null ) renderSessionListFromCache ( ) ; } , 50 ) ;
} ;
inp . onkeydown = e2 => {
if ( e2 . key === 'Enter' ) { e2 . preventDefault ( ) ; e2 . stopPropagation ( ) ; finish ( true ) ; }
if ( e2 . key === 'Escape' ) { e2 . preventDefault ( ) ; e2 . stopPropagation ( ) ; finish ( false ) ; }
} ;
// onblur: cancel only -- no accidental saves
inp . onblur = ( ) => { if ( _renamingSid === s . session _id ) finish ( false ) ; } ;
title . replaceWith ( inp ) ;
setTimeout ( ( ) => { inp . focus ( ) ; inp . select ( ) ; } , 10 ) ;
} ;
2026-04-02 18:08:34 +00:00
// Pin indicator (inline, only when pinned — no space reserved otherwise)
if ( s . pinned ) {
const pinInd = document . createElement ( 'span' ) ;
pinInd . className = 'session-pin-indicator' ;
pinInd . innerHTML = ICONS . pin ;
el . appendChild ( pinInd ) ;
}
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
// Project indicator: colored left border (active item keeps its own gold color)
2026-04-02 18:08:34 +00:00
if ( s . project _id ) {
const proj = _allProjects . find ( p => p . project _id === s . project _id ) ;
if ( proj ) {
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
// project color shown via dot indicator, not left border
2026-04-02 18:08:34 +00:00
const dot = document . createElement ( 'span' ) ;
dot . className = 'session-project-dot' ;
dot . style . background = proj . color || 'var(--blue)' ;
dot . title = proj . name ;
title . appendChild ( dot ) ;
}
}
el . appendChild ( title ) ;
const actions = document . createElement ( 'div' ) ;
actions . className = 'session-actions' ;
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
const menuBtn = document . createElement ( 'button' ) ;
menuBtn . type = 'button' ;
menuBtn . className = 'session-actions-trigger' ;
menuBtn . title = 'Conversation actions' ;
menuBtn . setAttribute ( 'aria-haspopup' , 'menu' ) ;
menuBtn . setAttribute ( 'aria-label' , 'Conversation actions' ) ;
menuBtn . innerHTML = ICONS . more ;
menuBtn . onclick = ( e ) => {
e . stopPropagation ( ) ;
e . preventDefault ( ) ;
_openSessionActionMenu ( s , menuBtn ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
} ;
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
actions . appendChild ( menuBtn ) ;
2026-04-02 18:08:34 +00:00
el . appendChild ( actions ) ;
2026-03-31 03:40:19 +00:00
// Use a click timer to distinguish single-click (navigate) from double-click (rename).
// This prevents loadSession from firing on the first click of a double-click,
// which would re-render the list and destroy the dblclick target before it fires.
let _clickTimer = null ;
el . onclick = async ( e ) => {
if ( _renamingSid ) return ; // ignore while any rename is active
2026-04-02 18:08:34 +00:00
if ( actions . contains ( e . target ) ) return ;
2026-03-31 03:40:19 +00:00
clearTimeout ( _clickTimer ) ;
_clickTimer = setTimeout ( async ( ) => {
_clickTimer = null ;
if ( _renamingSid ) return ;
2026-04-04 03:55:29 +00:00
// For CLI sessions, import into WebUI store first (idempotent)
if ( s . is _cli _session ) {
try {
await api ( '/api/session/import_cli' , { method : 'POST' , body : JSON . stringify ( { session _id : s . session _id } ) } ) ;
} catch ( e ) { /* import failed -- fall through to read-only view */ }
}
2026-03-31 03:40:19 +00:00
await loadSession ( s . session _id ) ; renderSessionListFromCache ( ) ;
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>
2026-04-03 17:09:36 +00:00
if ( typeof closeMobileSidebar === 'function' ) closeMobileSidebar ( ) ;
2026-03-31 03:40:19 +00:00
} , 220 ) ;
} ;
el . ondblclick = async ( e ) => {
e . stopPropagation ( ) ;
e . preventDefault ( ) ;
clearTimeout ( _clickTimer ) ; // cancel the pending single-click navigation
_clickTimer = null ;
startRename ( ) ;
} ;
2026-04-04 21:05:00 +00:00
return el ;
2026-03-31 03:40:19 +00:00
}
}
async function deleteSession ( sid ) {
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
const _delSess = await showConfirmDialog ( { title : 'Delete conversation' , message : 'This cannot be undone.' , confirmLabel : 'Delete' , danger : true , focusCancel : true } ) ;
if ( ! _delSess ) return ;
2026-03-31 03:40:19 +00:00
try {
await api ( '/api/session/delete' , { method : 'POST' , body : JSON . stringify ( { session _id : sid } ) } ) ;
} catch ( e ) { setStatus ( ` Delete failed: ${ e . message } ` ) ; return ; }
if ( S . session && S . session . session _id === sid ) {
S . session = null ; S . messages = [ ] ; S . entries = [ ] ;
localStorage . removeItem ( 'hermes-webui-session' ) ;
// load the most recent remaining session, or show blank if none left
const remaining = await api ( '/api/sessions' ) ;
if ( remaining . sessions && remaining . sessions . length ) {
await loadSession ( remaining . sessions [ 0 ] . session _id ) ;
} else {
2026-04-06 05:10:59 +00:00
$ ( 'topbarTitle' ) . textContent = window . _botName || 'Hermes' ;
2026-03-31 03:40:19 +00:00
$ ( 'topbarMeta' ) . textContent = 'Start a new conversation' ;
$ ( 'msgInner' ) . innerHTML = '' ;
$ ( 'emptyState' ) . style . display = '' ;
$ ( 'fileTree' ) . innerHTML = '' ;
}
}
showToast ( 'Conversation deleted' ) ;
await renderSessionList ( ) ;
}
2026-04-02 06:55:21 +00:00
// ── Project helpers ─────────────────────────────────────────────────────
const PROJECT _COLORS = [ '#7cb9ff' , '#f5c542' , '#e94560' , '#50c878' , '#c084fc' , '#fb923c' , '#67e8f9' , '#f472b6' ] ;
function _showProjectPicker ( session , anchorEl ) {
// Close any existing picker
document . querySelectorAll ( '.project-picker' ) . forEach ( p => p . remove ( ) ) ;
const picker = document . createElement ( 'div' ) ;
picker . className = 'project-picker' ;
// "No project" option
const none = document . createElement ( 'div' ) ;
none . className = 'project-picker-item' + ( ! session . project _id ? ' active' : '' ) ;
none . textContent = 'No project' ;
none . onclick = async ( ) => {
picker . remove ( ) ;
2026-04-02 18:18:20 +00:00
document . removeEventListener ( 'click' , close ) ;
2026-04-02 06:55:21 +00:00
await api ( '/api/session/move' , { method : 'POST' , body : JSON . stringify ( { session _id : session . session _id , project _id : null } ) } ) ;
session . project _id = null ;
renderSessionListFromCache ( ) ;
showToast ( 'Removed from project' ) ;
} ;
picker . appendChild ( none ) ;
// Project options
for ( const p of _allProjects ) {
const item = document . createElement ( 'div' ) ;
item . className = 'project-picker-item' + ( session . project _id === p . project _id ? ' active' : '' ) ;
if ( p . color ) {
const dot = document . createElement ( 'span' ) ;
dot . className = 'color-dot' ;
dot . style . cssText = 'width:6px;height:6px;border-radius:50%;background:' + p . color + ';flex-shrink:0;' ;
item . appendChild ( dot ) ;
}
const name = document . createElement ( 'span' ) ;
name . textContent = p . name ;
item . appendChild ( name ) ;
item . onclick = async ( ) => {
picker . remove ( ) ;
2026-04-02 18:18:20 +00:00
document . removeEventListener ( 'click' , close ) ;
2026-04-02 06:55:21 +00:00
await api ( '/api/session/move' , { method : 'POST' , body : JSON . stringify ( { session _id : session . session _id , project _id : p . project _id } ) } ) ;
session . project _id = p . project _id ;
renderSessionListFromCache ( ) ;
showToast ( 'Moved to ' + p . name ) ;
} ;
picker . appendChild ( item ) ;
}
2026-04-02 18:18:20 +00:00
// "+ New project" shortcut at the bottom
2026-04-02 08:10:18 +00:00
const createItem = document . createElement ( 'div' ) ;
createItem . className = 'project-picker-item project-picker-create' ;
createItem . textContent = '+ New project' ;
createItem . onclick = async ( ) => {
picker . remove ( ) ;
document . removeEventListener ( 'click' , close ) ;
2026-04-02 18:18:20 +00:00
// Prompt for name inline
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
const name = await showPromptDialog ( { title : 'New project' , message : '' , placeholder : 'Project name' , confirmLabel : t ( 'create' ) } ) ;
2026-04-02 08:10:18 +00:00
if ( ! name || ! name . trim ( ) ) return ;
const color = PROJECT _COLORS [ _allProjects . length % PROJECT _COLORS . length ] ;
const res = await api ( '/api/projects/create' , { method : 'POST' , body : JSON . stringify ( { name : name . trim ( ) , color } ) } ) ;
if ( res . project ) {
_allProjects . push ( res . project ) ;
2026-04-02 18:18:20 +00:00
// Now move session into it
2026-04-02 08:10:18 +00:00
await api ( '/api/session/move' , { method : 'POST' , body : JSON . stringify ( { session _id : session . session _id , project _id : res . project . project _id } ) } ) ;
session . project _id = res . project . project _id ;
await renderSessionList ( ) ;
showToast ( 'Created "' + res . project . name + '" and moved session' ) ;
}
} ;
picker . appendChild ( createItem ) ;
2026-04-02 18:18:20 +00:00
// Append to body and position using getBoundingClientRect so it isn't clipped
// by overflow:hidden on .session-item ancestors
2026-04-02 08:10:18 +00:00
document . body . appendChild ( picker ) ;
const rect = anchorEl . getBoundingClientRect ( ) ;
picker . style . position = 'fixed' ;
picker . style . zIndex = '999' ;
2026-04-02 18:18:20 +00:00
// Prefer opening below; flip above if too close to bottom of viewport
2026-04-02 08:10:18 +00:00
const spaceBelow = window . innerHeight - rect . bottom ;
if ( spaceBelow < 160 && rect . top > 160 ) {
picker . style . bottom = ( window . innerHeight - rect . top + 4 ) + 'px' ;
picker . style . top = 'auto' ;
} else {
picker . style . top = ( rect . bottom + 4 ) + 'px' ;
picker . style . bottom = 'auto' ;
}
2026-04-02 18:18:20 +00:00
// Align right edge of picker with right edge of button; keep within viewport
const pickerW = Math . min ( 220 , Math . max ( 160 , picker . scrollWidth || 160 ) ) ;
2026-04-02 08:10:18 +00:00
let left = rect . right - pickerW ;
if ( left < 8 ) left = 8 ;
picker . style . left = left + 'px' ;
2026-04-02 18:18:20 +00:00
// Close on outside click
const close = ( e ) => { if ( ! picker . contains ( e . target ) && e . target !== anchorEl ) { picker . remove ( ) ; document . removeEventListener ( 'click' , close ) ; } } ;
2026-04-02 06:55:21 +00:00
setTimeout ( ( ) => document . addEventListener ( 'click' , close ) , 0 ) ;
}
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
async function _startProjectCreate ( bar , addBtn ) {
2026-04-02 06:55:21 +00:00
const inp = document . createElement ( 'input' ) ;
inp . className = 'project-create-input' ;
inp . placeholder = 'Project name' ;
const finish = async ( save ) => {
if ( save && inp . value . trim ( ) ) {
const color = PROJECT _COLORS [ _allProjects . length % PROJECT _COLORS . length ] ;
await api ( '/api/projects/create' , { method : 'POST' , body : JSON . stringify ( { name : inp . value . trim ( ) , color } ) } ) ;
await renderSessionList ( ) ;
showToast ( 'Project created' ) ;
} else {
inp . replaceWith ( addBtn ) ;
}
} ;
inp . onkeydown = ( e ) => {
if ( e . key === 'Enter' ) { e . preventDefault ( ) ; finish ( true ) ; }
if ( e . key === 'Escape' ) { e . preventDefault ( ) ; finish ( false ) ; }
} ;
inp . onblur = ( ) => finish ( false ) ;
addBtn . replaceWith ( inp ) ;
setTimeout ( ( ) => inp . focus ( ) , 10 ) ;
}
function _startProjectRename ( proj , chip ) {
const inp = document . createElement ( 'input' ) ;
inp . className = 'project-create-input' ;
inp . value = proj . name ;
const finish = async ( save ) => {
if ( save && inp . value . trim ( ) && inp . value . trim ( ) !== proj . name ) {
await api ( '/api/projects/rename' , { method : 'POST' , body : JSON . stringify ( { project _id : proj . project _id , name : inp . value . trim ( ) } ) } ) ;
await renderSessionList ( ) ;
showToast ( 'Project renamed' ) ;
} else {
renderSessionListFromCache ( ) ;
}
} ;
inp . onkeydown = ( e ) => {
if ( e . key === 'Enter' ) { e . preventDefault ( ) ; finish ( true ) ; }
if ( e . key === 'Escape' ) { e . preventDefault ( ) ; finish ( false ) ; }
} ;
inp . onblur = ( ) => finish ( false ) ;
inp . onclick = ( e ) => e . stopPropagation ( ) ;
chip . replaceWith ( inp ) ;
setTimeout ( ( ) => { inp . focus ( ) ; inp . select ( ) ; } , 10 ) ;
}
async function _confirmDeleteProject ( proj ) {
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
const _delProj = await showConfirmDialog ( { title : ` Delete project " ${ proj . name } "? ` , message : 'Sessions will be unassigned but not deleted.' , confirmLabel : 'Delete' , danger : true , focusCancel : true } ) ;
if ( ! _delProj ) return ;
2026-04-02 06:55:21 +00:00
await api ( '/api/projects/delete' , { method : 'POST' , body : JSON . stringify ( { project _id : proj . project _id } ) } ) ;
if ( _activeProject === proj . project _id ) _activeProject = null ;
await renderSessionList ( ) ;
showToast ( 'Project deleted' ) ;
}
2026-03-31 03:40:19 +00:00