feat(#4717): empty-profile loading skeleton (phase 1.5 of #4662)

The profile-switch skeleton (#4671) always rendered a fixed content shape; for
a brand-new/empty profile that flashed a misleading 'loading conversations'
skeleton that resolved to an empty list. Track each profile's last-seen session
count (localStorage, per profile name, recorded after each non-all-profiles
render) and, when switching into a profile KNOWN to have zero sessions, render a
quiet empty-state placeholder instead of the content skeleton. Unknown profiles
keep the content skeleton (safe default); skipped under an active project/source
filter (the count is an unfiltered total). prefers-reduced-motion + light/dark
aware. Frontend-only. 7 source-structural tests; Phase-1 skeleton tests still
green.
This commit is contained in:
nesquena-hermes committed 2026-06-23 05:44:57 +00:00
1 parent 0953e7ca8e
commit 505465a81e
5 files changed
+218 -35

No files matched your search

+4
View File
@@ -3,6 +3,10 @@
## [Unreleased]
### Changed
- **Switching into a brand-new / empty profile no longer flashes a misleading "loading conversations" skeleton.** The profile-switch loading skeleton (#4671) always rendered a fixed content shape (group labels + rows); for a profile with zero conversations that implied data which never arrived, then resolved to an empty list — a small "where did my conversations go?" beat. The WebUI now records each profile's last-seen conversation count and, when switching into a profile it already knows has none, shows a quiet empty-state placeholder instead of the content skeleton (an unknown profile still gets the normal skeleton, so a profile that may have conversations is never under-shown). The empty placeholder respects `prefers-reduced-motion` and adapts to light/dark themes. Skipped while a project/source filter is active (the per-profile count is an unfiltered total). Phase 1.5 of the profile-switch UX work. (#4717)
## [v0.51.599] — 2026-06-23 — Release VF (dedupe live progress reasoning echoes)
### Fixed
+1 -1
View File
@@ -5978,7 +5978,7 @@ async function switchToProfile(name) {
// skeleton, before the new-profile cookie is set) also can't paint the old profile's
// rows. Cleared right before the switch-owned renderSessionList() and on failure.
if (typeof _setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(true);
if (typeof showSessionListSkeleton === 'function') showSessionListSkeleton();
if (typeof showSessionListSkeleton === 'function') showSessionListSkeleton(name);
// invalidate any in-flight workspace-tree load UNCONDITIONALLY at switch start — even
// when the panel is closed, loadDir('.') still runs later, and an empty-session switch
// reuses the same session_id so loadDir's id guard alone can't reject a stale
+121 -34
View File
@@ -170,6 +170,15 @@ function _clearComposerDraft(sid) {
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';
const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming';
// Per-profile session-count cache (issue #4717 / #4662 Phase 1.5). Records how
// many sessions each profile rendered last time, keyed by profile name, so a
// profile switch can pick an honest loading skeleton BEFORE the new /api/sessions
// fetch resolves: a profile we last saw with zero sessions shows an empty-state
// placeholder instead of a content skeleton that implies data which never arrives.
// A profile we've never recorded falls back to the normal content skeleton (safe
// default — never hide a skeleton for a profile that may well have conversations).
const SESSION_PROFILE_COUNTS_KEY = 'hermes-session-profile-counts';
let _sessionProfileCounts = null;
let _sessionViewedCounts = null;
let _sessionCompletionUnread = null;
let _sessionObservedStreaming = null;
@@ -212,6 +221,45 @@ function _getSessionViewedCounts() {
return _sessionViewedCounts;
}
// ── Per-profile session-count cache (#4717) ──────────────────────────────────
function _getSessionProfileCounts() {
if (_sessionProfileCounts !== null) return _sessionProfileCounts;
try {
const parsed = JSON.parse(localStorage.getItem(SESSION_PROFILE_COUNTS_KEY) || '{}');
_sessionProfileCounts = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (_){
_sessionProfileCounts = {};
}
return _sessionProfileCounts;
}
// Record how many sessions a profile currently shows, so the NEXT switch into
// it can pick an honest skeleton. Called after a real list render resolves.
function _recordSessionProfileCount(profile, count) {
const name = (profile || '').trim();
if (!name) return;
const n = Number(count);
if (!Number.isFinite(n) || n < 0) return;
const counts = _getSessionProfileCounts();
if (counts[name] === n) return; // no-op write avoidance
counts[name] = n;
try {
localStorage.setItem(SESSION_PROFILE_COUNTS_KEY, JSON.stringify(counts));
} catch (_){
// Ignore localStorage write failures (private mode / quota).
}
}
// Return the last-known session count for a profile, or null if we've never
// recorded one (caller must treat null as "unknown" → keep the content skeleton).
function _knownSessionProfileCount(profile) {
const name = (profile || '').trim();
if (!name) return null;
const counts = _getSessionProfileCounts();
const v = counts[name];
return (typeof v === 'number' && Number.isFinite(v)) ? v : null;
}
function _saveSessionViewedCounts() {
try {
localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(_getSessionViewedCounts()));
@@ -3538,42 +3586,14 @@ const _SESSION_SKELETON_GROUPS = [
// anatomy (group labels + single-line title bars with a short timestamp bar).
// Called the instant a profile switch begins so the user never sees the
// previous profile's conversations.
function showSessionListSkeleton(){
function showSessionListSkeleton(targetProfile){
const list = $('sessionList');
if(!list) return;
const wrap = document.createElement('div');
wrap.className = 'skeleton-list';
wrap.setAttribute('aria-hidden', 'true');
let rowIndex = 0;
for(const group of _SESSION_SKELETON_GROUPS){
const label = document.createElement('div');
label.className = 'skeleton-group-label';
wrap.appendChild(label);
for(const spec of group.rows){
const row = document.createElement('div');
row.className = 'skeleton-row';
// Stagger the fade-in per row. Set inline (not via CSS :nth-child) because
// group-label siblings are interleaved with rows, so a :nth-child stagger
// would skip most rows. Cap so the longest list doesn't feel laggy.
row.style.animationDelay = Math.min(rowIndex * 0.025, 0.2) + 's';
rowIndex++;
const title = document.createElement('div');
title.className = 'skeleton-bar skeleton-title';
title.style.width = spec.title + '%';
const stamp = document.createElement('div');
stamp.className = 'skeleton-bar skeleton-stamp';
row.appendChild(title);
row.appendChild(stamp);
wrap.appendChild(row);
}
}
list.innerHTML = '';
list.appendChild(wrap);
list.scrollTop = 0;
// Tear down any active virtual-scroll state so a pending scroll-driven render
// can't repaint the previous profile's cached rows over this skeleton (#4662
// Codex gate). Cancel the queued RAF and drop the data-session-virtual-*
// window markers; the real render rebuilds them from the new payload.
// Tear down any active virtual-scroll state up front so a pending scroll-driven
// render can't repaint the previous profile's cached rows over the skeleton
// (#4662 Codex gate). Cancel the queued RAF and drop the data-session-virtual-*
// window markers; the real render rebuilds them from the new payload. Done once
// here so it applies to BOTH the content and empty-state skeleton branches.
if(typeof _sessionVirtualScrollRaf!=='undefined'&&_sessionVirtualScrollRaf){
cancelAnimationFrame(_sessionVirtualScrollRaf);
_sessionVirtualScrollRaf=0;
@@ -3583,6 +3603,61 @@ function showSessionListSkeleton(){
delete list.dataset.sessionVirtualEnd;
delete list.dataset.sessionVirtualFilter;
delete list.dataset.sessionVirtualActiveAnchor;
// #4717: if we already know (from a prior render) the profile we're switching
// INTO has zero conversations, a full content skeleton (group labels + 8 rows)
// is misleading — it implies data that will never arrive, then resolves to an
// empty list. Render a quiet empty-state placeholder instead. Only when the
// count is KNOWN to be 0; an unknown profile (null) keeps the content skeleton
// (safe default — never hide a skeleton for a profile that may have sessions).
// Skip the empty branch while a project/source filter is active, since the
// per-profile count is an unfiltered total and could be non-zero overall yet
// empty under the filter (or vice-versa) — the content skeleton is the safe
// choice there. typeof guards keep this safe if the helper isn't in scope.
const knownCount = (typeof targetProfile === 'string' && targetProfile
&& typeof _knownSessionProfileCount === 'function')
? _knownSessionProfileCount(targetProfile) : null;
const filterActive = (typeof _activeProject !== 'undefined' && _activeProject)
|| (typeof _sessionSourceFilter !== 'undefined' && _sessionSourceFilter === 'cli');
const wrap = document.createElement('div');
wrap.setAttribute('aria-hidden', 'true');
if(knownCount === 0 && !filterActive){
// A single faint placeholder bar rather than a "no conversations" text — the
// real empty-state note paints the instant the (fast, empty) fetch resolves,
// so we just hold a calm, content-free space in the meantime (no flash of a
// fake list, no premature wording).
wrap.className = 'skeleton-list skeleton-list-empty';
const bar = document.createElement('div');
bar.className = 'skeleton-empty-hint';
wrap.appendChild(bar);
} else {
wrap.className = 'skeleton-list';
let rowIndex = 0;
for(const group of _SESSION_SKELETON_GROUPS){
const label = document.createElement('div');
label.className = 'skeleton-group-label';
wrap.appendChild(label);
for(const spec of group.rows){
const row = document.createElement('div');
row.className = 'skeleton-row';
// Stagger the fade-in per row. Set inline (not via CSS :nth-child) because
// group-label siblings are interleaved with rows, so a :nth-child stagger
// would skip most rows. Cap so the longest list doesn't feel laggy.
row.style.animationDelay = Math.min(rowIndex * 0.025, 0.2) + 's';
rowIndex++;
const title = document.createElement('div');
title.className = 'skeleton-bar skeleton-title';
title.style.width = spec.title + '%';
const stamp = document.createElement('div');
stamp.className = 'skeleton-bar skeleton-stamp';
row.appendChild(title);
row.appendChild(stamp);
wrap.appendChild(row);
}
}
}
list.innerHTML = '';
list.appendChild(wrap);
list.scrollTop = 0;
_sessionListSkeletonActive = true;
}
@@ -3762,6 +3837,18 @@ function _applySessionListPayload(sessData, projData){
: (S.activeProfile || 'default'),
allProfiles: !!_showAllProfiles,
};
// Record this profile's session count so the NEXT switch into it can pick an
// honest skeleton (empty-state vs content) before its fetch resolves (#4717).
// Only record an UNFILTERED total: skip all-profiles (conflates profiles), and
// skip while a project or CLI-source filter is active (those record a filtered
// subset that could cache a misleading 0 for a profile that has sessions under
// a different filter). This mirrors the read-side `filterActive` gate in
// showSessionListSkeleton so the write and read agree on what the count means.
const _recordFilterActive = (typeof _activeProject !== 'undefined' && _activeProject)
|| (typeof _sessionSourceFilter !== 'undefined' && _sessionSourceFilter === 'cli');
if (!_showAllProfiles && !_recordFilterActive) {
_recordSessionProfileCount(_allSessionsScope.profile, _allSessions.length);
}
_syncSessionAttentionSoundState(_allSessions);
_pruneLineageReportCacheToVisibleSessions(_allSessions);
_allProjects = projData.projects||[];
+9
View File
@@ -1256,6 +1256,15 @@
.skeleton-bar,.skeleton-tree-row .skeleton-glyph{animation:none;background:var(--skeleton-base,color-mix(in srgb,var(--text) 10%,transparent));}
.skeleton-row,.skeleton-tree-row,.skeleton-group-label{animation:none;}
}
/* #4717: empty-profile skeleton. When the target profile is known to have zero
conversations, showSessionListSkeleton() renders a single quiet hint bar
instead of a fake content list, so the switch resolves calmly to the real
empty state without a misleading "loading conversations" flash. */
.skeleton-list-empty{padding:14px 10px;}
.skeleton-empty-hint{height:9px;width:42px;border-radius:var(--radius-pill);
background:var(--skeleton-base,color-mix(in srgb,var(--text) 8%,transparent));
opacity:.4;animation:skeletonFadeInDim .22s ease both;}
@media (prefers-reduced-motion:reduce){.skeleton-empty-hint{animation:none;}}
.session-item.new-flash{animation:newflash 1.4s ease-out forwards;}
.session-item.session-list-flip-enter{animation:sessionListFlipIn .28s cubic-bezier(.2,.8,.2,1) backwards;transform-origin:center top;}
/* Collapsible date group headers */
@@ -0,0 +1,83 @@
"""Phase 1.5 (#4717): empty/zero-conversation profile shows a quiet empty-state
skeleton on switch instead of a misleading content skeleton.
These are source-level structural guards (the skeleton logic is browser JS; the
behavioural check is a manual browser pass documented in the PR). They pin that
the per-profile count cache + the empty-branch wiring exist and are connected, so
a refactor can't silently drop the empty-profile affordance.
"""
import re
from pathlib import Path
import api.routes as routes # noqa: F401 (ensures repo import path is set up)
SESSIONS_JS = Path(__file__).resolve().parent.parent / "static" / "sessions.js"
PANELS_JS = Path(__file__).resolve().parent.parent / "static" / "panels.js"
STYLE_CSS = Path(__file__).resolve().parent.parent / "static" / "style.css"
def _sessions():
return SESSIONS_JS.read_text(encoding="utf-8")
def test_per_profile_count_cache_helpers_exist():
src = _sessions()
assert "SESSION_PROFILE_COUNTS_KEY" in src
assert "function _recordSessionProfileCount(" in src
assert "function _knownSessionProfileCount(" in src
def test_count_is_recorded_after_render():
"""The render path must persist the active profile's session count so the
next switch can consult it — only for an unfiltered, single-profile view."""
src = _sessions()
assert "_recordSessionProfileCount(_allSessionsScope.profile, _allSessions.length)" in src
# Gated on !all-profiles AND !filter-active so only an unfiltered total is cached
# (mirrors the read-side gate; a filtered subset must not cache a misleading 0).
record_idx = src.index("_recordSessionProfileCount(_allSessionsScope.profile")
window = src[record_idx - 400:record_idx]
assert "_showAllProfiles" in window, "count record must be gated on !_showAllProfiles"
assert "_recordFilterActive" in window, "count record must be gated on !filterActive (write/read parity)"
def test_skeleton_accepts_target_profile_and_has_empty_branch():
src = _sessions()
assert "function showSessionListSkeleton(targetProfile)" in src
# Empty branch keys off a KNOWN zero count (null/unknown keeps content skeleton).
assert "_knownSessionProfileCount(targetProfile)" in src
assert "knownCount === 0" in src
assert "skeleton-list-empty" in src
def test_empty_branch_skipped_when_filter_active():
"""A project/source filter makes the unfiltered per-profile count unreliable,
so the empty branch must be suppressed when a filter is active."""
src = _sessions()
assert "filterActive" in src
# The guard combines the known-zero count with !filterActive.
assert "knownCount === 0 && !filterActive" in src
def test_switch_call_site_passes_target_profile():
src = PANELS_JS.read_text(encoding="utf-8")
assert "showSessionListSkeleton(name)" in src, "profile switch must pass the target profile to the skeleton"
def test_empty_skeleton_css_respects_reduced_motion():
css = STYLE_CSS.read_text(encoding="utf-8")
assert ".skeleton-empty-hint" in css
# The empty hint must have a reduced-motion override (no animation).
rm_blocks = re.findall(r"@media\s*\(prefers-reduced-motion:reduce\)\s*\{[^}]*skeleton-empty-hint[^}]*\}", css)
assert rm_blocks, "skeleton-empty-hint must have a prefers-reduced-motion:reduce override"
def test_virtual_scroll_teardown_runs_for_skeleton():
"""The skeleton builder must tear down virtual-scroll state (the #4662
Codex-gate guard against stale-row repaint) — done once up front so it
applies to both the content and empty-state branches."""
src = _sessions()
idx = src.index("function showSessionListSkeleton(")
body = src[idx: idx + 2000]
assert "cancelAnimationFrame(_sessionVirtualScrollRaf)" in body
assert "delete list.dataset.sessionVirtualTotal" in body
assert "delete list.dataset.sessionVirtualActiveAnchor" in body