diff --git a/CHANGELOG.md b/CHANGELOG.md index 765f626f..60d499bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/panels.js b/static/panels.js index 36140823..f77e547f 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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 diff --git a/static/sessions.js b/static/sessions.js index 7e3d5842..20481c63 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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||[]; diff --git a/static/style.css b/static/style.css index dd9c8cbb..a4b73a5a 100644 --- a/static/style.css +++ b/static/style.css @@ -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 */ diff --git a/tests/test_issue4717_empty_profile_skeleton.py b/tests/test_issue4717_empty_profile_skeleton.py new file mode 100644 index 00000000..7d6141a1 --- /dev/null +++ b/tests/test_issue4717_empty_profile_skeleton.py @@ -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