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:
1 parent
0953e7ca8e
commit
505465a81e
5 files changed
+218
-35
No files matched your search
@@ -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
@@ -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
@@ -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||[];
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user