release #5562: refresh sidebar recency after missed session events - #5591
Conversation
A reactivated older conversation wasn't bumped to the top / into Today until a manual refresh when the viewing tab was backgrounded/unfocused: refreshSessionList early-returns on document.hidden unless force=true, and the sidebar SSE closes on blur — but the in-flight/pending coalescing only preserved the refresh REASON string, dropping the opts (force/refreshActive). So a coalesced 'sessions_changed' refresh lost its force flag and got skipped on a hidden tab. Now the pending request preserves merged opts (force/refreshActive OR-merged), and a dedicated resume-refresh path forces a catch-up when the sidebar regains focus, so cross-device recency re-sorts land without a manual refresh. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
|
| Filename | Overview |
|---|---|
| static/sessions.js | Core fix: pending refresh state upgraded from bare reason string to {reason, opts} with OR-merge of force/refreshActive flags; _closeSessionEventsSSE now sets NeedsRefreshOnOpen; sessions_changed events now always pass force:true to bypass document.hidden guard |
| tests/test_webui_external_refresh_frontend.py | Three new Node.js-executed integration tests verifying opts coalescing, force survival, and timer merging; introduces a brace-depth _function_body helper that differs from the regex-based one already in test_issue3916_external_refresh_poll.py |
| tests/test_issue3916_external_refresh_poll.py | Updated assertions to match new _scheduleSessionEventsRefresh signature and sessions_changed call site; structurally unchanged |
| tests/test_issue4151_pwa_focus_sse.py | One new assertion verifying the focus hook now calls _refreshSessionListAfterSidebarResume instead of refreshSessionList directly |
| CHANGELOG.md | New changelog entry describing the sidebar recency fix; part of the release process |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[SSE sessions_changed event] -->|force:true, refreshActive:true| B[_scheduleSessionEventsRefresh]
B --> C{_sessionEventsRefreshPendingRequest\nOR-merge force/refreshActive}
C -->|timer already running| D[Merge opts into pending request]
C -->|no timer| E[Set pending request\nStart 300ms timer]
E --> F[Timer fires]
D --> F
F --> G[refreshSessionList\nrequest.reason, request.opts]
G --> H{_sessionListRefreshInFlight?}
H -->|yes| I[Coalesce: OR-merge opts\ninto _sessionListRefreshPendingRequest]
H -->|no| J{force:true OR\ndocument.hidden==false?}
J -->|no force AND hidden| K[Early return - skipped]
J -->|force OR visible| L[renderSessionList\nrefreshActiveSession if refreshActive]
L --> M[finally: check pending]
M -->|pending exists| N[_scheduleSessionEventsRefresh\nwith preserved opts]
M -->|no pending| O[Done]
P[Tab blur/hidden] --> Q[_closeSessionEventsSSE\nsets NeedsRefreshOnOpen=true]
R[Tab focus] --> S[_refreshSessionListAfterSidebarResume\nclears NeedsRefreshOnOpen\nforce:true refresh]
T[SSE onopen] --> U{NeedsRefreshOnOpen?}
U -->|true| V[_refreshSessionListAfterSidebarResume\nreconnect]
U -->|false| W[No-op - focus already handled]
%%{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[SSE sessions_changed event] -->|force:true, refreshActive:true| B[_scheduleSessionEventsRefresh]
B --> C{_sessionEventsRefreshPendingRequest\nOR-merge force/refreshActive}
C -->|timer already running| D[Merge opts into pending request]
C -->|no timer| E[Set pending request\nStart 300ms timer]
E --> F[Timer fires]
D --> F
F --> G[refreshSessionList\nrequest.reason, request.opts]
G --> H{_sessionListRefreshInFlight?}
H -->|yes| I[Coalesce: OR-merge opts\ninto _sessionListRefreshPendingRequest]
H -->|no| J{force:true OR\ndocument.hidden==false?}
J -->|no force AND hidden| K[Early return - skipped]
J -->|force OR visible| L[renderSessionList\nrefreshActiveSession if refreshActive]
L --> M[finally: check pending]
M -->|pending exists| N[_scheduleSessionEventsRefresh\nwith preserved opts]
M -->|no pending| O[Done]
P[Tab blur/hidden] --> Q[_closeSessionEventsSSE\nsets NeedsRefreshOnOpen=true]
R[Tab focus] --> S[_refreshSessionListAfterSidebarResume\nclears NeedsRefreshOnOpen\nforce:true refresh]
T[SSE onopen] --> U{NeedsRefreshOnOpen?}
U -->|true| V[_refreshSessionListAfterSidebarResume\nreconnect]
U -->|false| W[No-op - focus already handled]
Reviews (1): Last reviewed commit: "release #5562: refresh sidebar recency a..." | Re-trigger Greptile
| @@ -7,6 +11,33 @@ | |||
| PANELS_JS = Path("static/panels.js").read_text(encoding="utf-8") | |||
|
|
|||
|
|
|||
| def _function_body(src: str, name: str) -> str: | |||
| # Extracted functions intentionally avoid braces in strings so this stays source-light. | |||
| m = re.search(rf"(?:async\s+)?function\s+{re.escape(name)}\b", src) | |||
| assert m, f"{name} function not found" | |||
| sig_end = src.find(")", m.end()) | |||
| assert sig_end != -1, f"{name} function signature not terminated" | |||
| brace_start = src.find("{", sig_end) | |||
| assert brace_start != -1, f"{name} function body not found" | |||
| depth = 0 | |||
| for idx in range(brace_start, len(src)): | |||
| ch = src[idx] | |||
| if ch == "{": | |||
| depth += 1 | |||
| elif ch == "}": | |||
| depth -= 1 | |||
There was a problem hiding this comment.
Two different
_function_body implementations now coexist in the test suite. The version in test_issue3916_external_refresh_poll.py uses a simple re.DOTALL | re.MULTILINE regex (.*?^}}), which can truncate at the first top-level } on its own line inside a nested block. The new version here uses brace-depth counting and is more robust for nested braces. Consider replacing the regex-based version in the other file with the brace-depth one (or sharing via conftest.py) so both files use the same, more correct helper — especially since the brace-depth version is now used to extract code that is actually executed in Node.js, where an incorrect extraction would silently produce wrong test results.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Release: #5562 — refresh sidebar recency after missed session events
Ships @rodboev's sidebar reliability fix for #5551, rebuilt on current
masterfrom the live PR head and fully gated.The bug (#5551)
A conversation reactivated from another device (or another tab) while your current tab was backgrounded/unfocused was not bumped to the top / into "Today" until you manually refreshed.
refreshSessionList()early-returns whiledocument.hiddenunlessforce:true, and the sidebar SSE closes on blur — but the in-flight/pending refresh coalescing only preserved the refresh reason string and dropped the opts, so a coalescedsessions_changedrefresh lost itsforce:trueand got skipped on a hidden tab.The fix (static/sessions.js)
{reason, opts}, with_mergeSessionListRefreshOptions()OR-mergingforce/refreshActiveacross coalesced requests so a forced refresh's flag survives._refreshSessionListAfterSidebarResume()— a dedicated resume path that clears the onopen catch-up flag and forces a refresh when the sidebar regains focus.Gate (green)
visibilitychange+focus) coalesces and lands in the existing Hermes WebUI feels laggy: /api/sessions and session loads take seconds #5455/perf(sidebar): skip full session-list rebuild when the payload is unchanged (#5455) #5467 render-skip no-op fast path → one quiet in-place re-sort, no flash, no skeleton; a hidden-tab rebuild storm can't happen (SSE closed while hidden).Attribution: original author @rodboev (
Co-authored-bytrailer preserved).Closes #5562
Closes #5551