Summary
The session sidebar list is functional but missing three UX elements that would significantly improve at-a-glance awareness: (a) a spinner/animation on sessions currently generating a response, (b) an unread dot indicator for sessions with new messages the user hasn't viewed yet, and (c) a human-readable timestamp showing the time of the last reply.
Inspired by Codex's session list UI design.
Current state
static/sessions.js renders session items via _renderOneSession(s). The session object (from api/models.py Session.compact()) already includes:
updated_at — Unix timestamp of last activity
message_count — total messages
session_id — stable ID
pinned, archived, project_id, profile
Active streaming state is tracked globally via S.busy (boolean, set in sessions.js) and S.activeStreamId. A session item gets the CSS class active when S.session.session_id === s.session_id — but no class for "streaming in progress".
What's missing for the three features:
(a) Spinning loader for active sessions
S.busy is true when any session is streaming, and S.session holds the active session. The session list already checks isActive = S.session && s.session_id === S.session.session_id, so we can combine isActive && S.busy to show a spinner. The issue is that renderSessionListFromCache() is called on every token (via S.busy state changes), so the spinner would appear/disappear correctly.
Currently no CSS class or DOM element signals an in-progress session to the sidebar item. A .session-item.streaming class could add a CSS animation.
(b) Unread dot indicator
The backend has no unread or last_viewed_at field in Session.compact(). Tracking "unread" requires knowing whether the user has viewed the latest messages. Options:
- Client-side: store
last_viewed_message_count[session_id] in localStorage and compare against s.message_count from the API
- Server-side: add
last_viewed_at timestamp to Session model, update it on /api/session GET
The client-side approach is simpler and avoids a schema change. It's imprecise (count-based, not content-based) but sufficient for this UX.
(c) Last reply timestamp
updated_at is already available in the session list payload (Session.compact() returns it, and /api/sessions emits it). The frontend utility _sessionTimestampMs(s) already reads it. The timestamp just isn't rendered in the item itself — only used for sorting and date-group headers.
The detailed sidebar density mode (window._sidebarDensity === 'detailed') already renders a session-meta div with message count and model. A timestamp could be appended here, or shown in compact mode as a right-aligned secondary label.
Proposed implementation
Frontend changes — static/sessions.js
Spinner (a):
// In _renderOneSession(s), after isActive determination:
const isStreaming = isActive && S.busy;
el.className = 'session-item'
+ (isActive ? ' active' : '')
+ (isStreaming ? ' streaming' : '')
+ ...;
// Add spinner element to title row when streaming
if (isStreaming) {
const spinner = document.createElement('span');
spinner.className = 'session-spinner';
titleRow.appendChild(spinner);
}
Unread dot (b):
// Read/write from localStorage
const VIEWED_KEY = 'hermes-viewed-counts';
const viewedCounts = JSON.parse(localStorage.getItem(VIEWED_KEY) || '{}');
// On loadSession(): viewedCounts[sid] = s.message_count; localStorage.setItem(...)
// In _renderOneSession(s):
const viewed = viewedCounts[s.session_id] || 0;
const hasUnread = !isActive && s.message_count > viewed;
if (hasUnread) {
const dot = document.createElement('span');
dot.className = 'session-unread-dot';
titleRow.appendChild(dot);
}
Timestamp (c):
// In _renderOneSession(s), after existing content:
const tsMs = _sessionTimestampMs(s);
if (tsMs > 0) {
const tsEl = document.createElement('span');
tsEl.className = 'session-timestamp';
tsEl.textContent = _relativeTime(tsMs); // e.g. "2m ago", "3h ago"
titleRow.appendChild(tsEl);
}
CSS changes — static/style.css
.session-spinner { /* 12px spinning circle using border animation */ }
.session-unread-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.session-timestamp { font-size: 10px; color: var(--muted); margin-left: auto; flex-shrink: 0; white-space: nowrap; }
@keyframes session-spin { to { transform: rotate(360deg); } }
Files involved
static/sessions.js — _renderOneSession() (~line 690), renderSessionListFromCache() (~line 555), loadSession() (wherever it sets the active session)
static/style.css — add .session-spinner, .session-unread-dot, .session-timestamp classes
api/models.py — Session.compact() (~line 168) — updated_at already present, no changes needed for (a) and (c); (b) can be purely frontend
Summary
The session sidebar list is functional but missing three UX elements that would significantly improve at-a-glance awareness: (a) a spinner/animation on sessions currently generating a response, (b) an unread dot indicator for sessions with new messages the user hasn't viewed yet, and (c) a human-readable timestamp showing the time of the last reply.
Inspired by Codex's session list UI design.
Current state
static/sessions.jsrenders session items via_renderOneSession(s). The session object (fromapi/models.py Session.compact()) already includes:updated_at— Unix timestamp of last activitymessage_count— total messagessession_id— stable IDpinned,archived,project_id,profileActive streaming state is tracked globally via
S.busy(boolean, set insessions.js) andS.activeStreamId. A session item gets the CSS classactivewhenS.session.session_id === s.session_id— but no class for "streaming in progress".What's missing for the three features:
(a) Spinning loader for active sessions
S.busyis true when any session is streaming, andS.sessionholds the active session. The session list already checksisActive = S.session && s.session_id === S.session.session_id, so we can combineisActive && S.busyto show a spinner. The issue is thatrenderSessionListFromCache()is called on every token (viaS.busystate changes), so the spinner would appear/disappear correctly.Currently no CSS class or DOM element signals an in-progress session to the sidebar item. A
.session-item.streamingclass could add a CSS animation.(b) Unread dot indicator
The backend has no
unreadorlast_viewed_atfield inSession.compact(). Tracking "unread" requires knowing whether the user has viewed the latest messages. Options:last_viewed_message_count[session_id]inlocalStorageand compare againsts.message_countfrom the APIlast_viewed_attimestamp to Session model, update it on/api/sessionGETThe client-side approach is simpler and avoids a schema change. It's imprecise (count-based, not content-based) but sufficient for this UX.
(c) Last reply timestamp
updated_atis already available in the session list payload (Session.compact()returns it, and/api/sessionsemits it). The frontend utility_sessionTimestampMs(s)already reads it. The timestamp just isn't rendered in the item itself — only used for sorting and date-group headers.The
detailedsidebar density mode (window._sidebarDensity === 'detailed') already renders asession-metadiv with message count and model. A timestamp could be appended here, or shown in compact mode as a right-aligned secondary label.Proposed implementation
Frontend changes —
static/sessions.jsSpinner (a):
Unread dot (b):
Timestamp (c):
CSS changes —
static/style.cssFiles involved
static/sessions.js—_renderOneSession()(~line 690),renderSessionListFromCache()(~line 555),loadSession()(wherever it sets the active session)static/style.css— add.session-spinner,.session-unread-dot,.session-timestampclassesapi/models.py—Session.compact()(~line 168) —updated_atalready present, no changes needed for (a) and (c); (b) can be purely frontend