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 ) ; }
}
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' : '' ) ;
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