Skip to content

feat(sessions): session list UX — loading spinner, unread dot, last-reply timestamp #856

@nesquena-hermes

Description

@nesquena-hermes

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:

  1. Client-side: store last_viewed_message_count[session_id] in localStorage and compare against s.message_count from the API
  2. 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.pySession.compact() (~line 168) — updated_at already present, no changes needed for (a) and (c); (b) can be purely frontend

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions