Skip to content

perf(sidebar): skip full session-list rebuild when the payload is unchanged (#5455) - #5467

Merged
3 commits merged into
nesquena:masterfrom
ai-ag2026:perf/sidebar-skip-unchanged
Jul 3, 2026
Merged

3 commits merged into
nesquena:masterfrom
ai-ag2026:perf/sidebar-skip-unchanged

Conversation

@ai-ag2026

Copy link
Copy Markdown
Contributor

Problem

The 30s streaming poll and the gateway poll re-applied /api/sessions and ran a full sidebar DOM rebuild (list.innerHTML='' + one node tree per row) on every tick — even when the payload was byte-identical to what was already on screen, the common case on an idle sidebar. Part of the #5455 latency work.

Change

Add a signature over the applied rows + projects + coarse display state in _applySessionListPayload, and skip renderSessionListFromCache() when it matches the last rendered signature and no entrance animation is pending. A streaming row's fields advance each poll, so its signature changes and it still repaints. Client-only display toggles (search, active, density, select, lineage expansion) render through their own paths. The per-refresh INFLIGHT cleanup still runs on the skip path, and the 60s relative-time refresh plus every other render trigger call renderSessionListFromCache directly and are unaffected.

Behavior

Output-neutral: a skip only happens when the render would produce the same DOM.

Verification

In-browser: over an idle window, applied-payloads > DOM-rebuilds (redundant rebuilds skipped); sidebar still updates immediately on real changes (new session, title change, session switch, streaming indicator). node --check + ESLint runtime guard pass.

Refs #5455.

🤖 Generated with Claude Code

…hanged (nesquena#5455)

The 30s streaming poll and the gateway poll re-applied /api/sessions and ran a
full sidebar DOM rebuild (list.innerHTML='' + one node tree per row) on every
tick, even when the payload was byte-identical to what was already on screen —
the common case on an idle sidebar.

Add a signature over the applied rows + projects + coarse display state in
_applySessionListPayload and skip renderSessionListFromCache() when it matches
the last rendered signature and no entrance animation is pending. A streaming
row's fields advance each poll so its signature changes and it still repaints;
client-only display toggles (search, active, density, select, lineage
expansion) render through their own paths. The per-refresh INFLIGHT cleanup is
still run on the skip path. The 60s relative-time refresh and all other render
triggers call renderSessionListFromCache directly and are unaffected.

Output-neutral: a skip only happens when the render would produce the same DOM.
node --check + ESLint runtime guard clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ai-ag2026
ai-ag2026 marked this pull request as ready for review July 3, 2026 17:18
@greptile-apps

greptile-apps Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a signature-based fast path inside _applySessionListPayload that short-circuits the full list.innerHTML = '' + node-tree rebuild when the serialized representation of all render-relevant state is unchanged from the last render — the common case during idle 30s/gateway polls.

  • _sessionListRenderSignature() serializes a curated slim projection of _allSessions (key display fields only), _allProjects, active session, search text, density, select mode, profile counts, and source filter into a JSON string; a serialization failure returns null, which always forces a rebuild.
  • Skip gate in _applySessionListPayload returns early (after running _purgeStaleInflightEntries) when _canRenderNow is true, no entrance animation is pending, the sig is non-null, and the sig matches the last cached value; streaming rows advance their fields on every poll so their sig changes and they still repaint.
  • Test coverage adds a structural assertion that the slimmed sessionKeys list is used (not raw _allSessions) and backfills new state globals into the pushdown test harness.

Confidence Score: 4/5

The optimisation is output-neutral in the common case, but the profile-switch path (flagged in a prior review) can leave a post-skeleton list unrendered when two profiles share an identical sig; that window exists until the 60s time-refresh fires.

The sig-skip logic is well-guarded against animation, rename, and action-menu state. The one outstanding concern — _lastSessionListRenderSig not being reset by _invalidateSessionListRenders on profile switch — was identified in a previous review pass and is not addressed by this PR, meaning the stale-skeleton scenario is still reachable in production for users with identically-shaped profiles.

static/sessions.js — specifically the interaction between _invalidateSessionListRenders and _lastSessionListRenderSig around profile switches.

Important Files Changed

Filename Overview
static/sessions.js Adds _sessionListRenderSignature() and a sig-based skip gate inside _applySessionListPayload to avoid full DOM rebuilds on idle polls; the skip is correctly guarded by animation/rename/action-menu state but _lastSessionListRenderSig is never reset by _invalidateSessionListRenders(), leaving a window where a profile-switch to an identically-shaped list (e.g. two empty profiles) skips the post-skeleton render.
tests/test_webui_external_refresh_frontend.py Adds a structural test verifying that _sessionListRenderSignature uses a slimmed session-key list rather than the raw _allSessions object, protecting against future schema additions silently breaking the skip.
tests/test_issue4766_sidebar_source_pushdown.py Backfills the new globals (_sessionListRefreshAnimationPending, _lastSessionListRenderSig, _renamingSid, _sessionActionMenu, mocked _sessionListRenderSignature / _purgeStaleInflightEntries) into the test harness so existing pushdown tests continue to pass with the new skip gate in _applySessionListPayload.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[_applySessionListPayload called\npoll / WS push] --> B[Update _allSessions / _allProjects\ncounts, filters]
    B --> C[_sessionListSkeletonActive = false]
    C --> D{_canRenderNow?\n!renamingSid && !actionMenu}
    D -- No --> G[renderSessionListFromCache\nno-ops per own guards]
    D -- Yes --> E[Compute _renderSig\n_sessionListRenderSignature]
    E --> F{animationPending?\nor sig null?\nor sig != last?}
    F -- Skip conditions NOT met\nidentical idle payload --> H[_purgeStaleInflightEntries\nreturn early - DOM untouched]
    F -- Sig changed or\nanimation pending --> I[_lastSessionListRenderSig = _renderSig]
    I --> J[renderSessionListFromCache\nfull DOM rebuild]
    J --> K[_sessionListRefreshAnimationPending = false\nanimate FLIP if pending]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[_applySessionListPayload called\npoll / WS push] --> B[Update _allSessions / _allProjects\ncounts, filters]
    B --> C[_sessionListSkeletonActive = false]
    C --> D{_canRenderNow?\n!renamingSid && !actionMenu}
    D -- No --> G[renderSessionListFromCache\nno-ops per own guards]
    D -- Yes --> E[Compute _renderSig\n_sessionListRenderSignature]
    E --> F{animationPending?\nor sig null?\nor sig != last?}
    F -- Skip conditions NOT met\nidentical idle payload --> H[_purgeStaleInflightEntries\nreturn early - DOM untouched]
    F -- Sig changed or\nanimation pending --> I[_lastSessionListRenderSig = _renderSig]
    I --> J[renderSessionListFromCache\nfull DOM rebuild]
    J --> K[_sessionListRefreshAnimationPending = false\nanimate FLIP if pending]
Loading

Reviews (3): Last reviewed commit: "fix: narrow sidebar render-skip signatur..." | Re-trigger Greptile

Comment thread static/sessions.js
@nesquena-hermes nesquena-hermes added the size:M Medium PR (≤10 files, ≤250 LOC) label Jul 3, 2026
@nesquena-hermes nesquena-hermes closed this pull request by merging all changes into nesquena:master in 7a700b9 Jul 3, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.844 — thanks @ai-ag2026! 🎉

The sidebar render-skip on idle polls is live (for #5455): the session list now skips its full DOM rebuild when a signature of the render inputs is unchanged, and rebuilds whenever anything visible changes.

This is a correctness-sensitive change (a false-skip = a stale sidebar), so it went through a deep dual gate and I fixed every finding on the release branch before shipping (all credited to you):

  • Round 1 (3 fixes): the signature omitted rendered fields (pending/running streaming state, attention dots, source/read-only/worktree/lineage/model/profile + reference rows). Fixed by serializing the FULL applied row objects + _sidebarReferenceSessions instead of a hand-picked allowlist — inherently complete, can't rot when a new rendered field is added.
  • Round 2 (2 fixes): recovering from the loading skeleton or the "couldn't load conversations" banner (both rendered outside the signature path) could false-skip and leave stale DOM. Fixed by capturing _hadSessionListSkeleton / _hadSessionListLoadError before clearing and forcing a real render on recovery.
  • Round 3: Codex SAFE TO SHIP + Opus safe to ship (no stale-sidebar path remains). Full suite green (2 unrelated pre-existing env flakes). Regression tests added for all of the above.

pull Bot pushed a commit to Mu-L/hermes-webui that referenced this pull request Jul 4, 2026
pull Bot pushed a commit to Mu-L/hermes-webui that referenced this pull request Jul 4, 2026
…/error-banner DOM

Codex re-gate found 2 more false-skips (DOM state OUTSIDE the signature path):
- CORE: profile-switch skeleton stays visible if the new payload signature
  matches the last one (empty/same-shaped profile) — skip fires over the skeleton.
- SILENT: stale 'Could not load conversations' banner persists when a transient
  fetch failure heals with identical rows.
Fix: capture _hadSessionListSkeleton + _hadSessionListLoadError BEFORE clearing
them, and disallow the identical-signature skip when either was set. + regression
test. (messages.js:1462 finding is a base-comparison artifact for nesquena#5472/nesquena#5479/nesquena#5484,
not in nesquena#5467's diff.)
pull Bot pushed a commit to Mu-L/hermes-webui that referenced this pull request Jul 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M Medium PR (≤10 files, ≤250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants