Merge pull request #5403 from nesquena/stage-batch-5399-5209

Release v0.51.822: #5399 session-list 502 retry + #5209 update-summary expand
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-07-01 22:26:39 -07:00
commit 1f2dac90df
9 files changed
+120 -16

No files matched your search

+2
View File
@@ -5,11 +5,13 @@
### Fixed
- **The sidebar recovers on its own after a brief server blip during a profile switch.** If the backend returned a transient 502/503/504 (e.g. an nginx→backend restart window) on a warm session-list refresh — a profile switch, tab focus, or reconnect — the sidebar gave up on the first try and sat on the previous profile's list until you hard-reloaded. The idempotent session-list fetch now retries those transient upstream statuses on every refresh (not just the first cold load), so it heals itself without a Ctrl+F5. Thanks @weidzhou for the report and root-cause. (#5394)
- **The sidebar no longer conjures a phantom "Cron Jobs" project you never asked for.** When you had no projects of your own, opening the sidebar source filter (or importing CLI sessions) would silently mint a "Cron Jobs" project just to group cron rows — cluttering a filter you'd never opted into. Cron sessions now stay as ordinary ungrouped rows (still visible in the sidebar and origin filter) until you actually create a project; an existing "Cron Jobs" project keeps resolving exactly as before, so nothing changes for anyone already using one. Thanks @rodboev. (#5398, #5379)
- **The composer footer shows your model and workspace names again on desktop.** The Export-to-HTML button added to the composer footer in the previous release pushed the control row past its width budget, which collapsed the footer into icon-only mode and hid the model / workspace / profile text labels. The export button has been removed from the composer footer (export remains available from Settings), so the labels are visible again.
### Added
- **The post-update "What's New" summary now has an Expand control.** The update banner's generated summary defaults to a compact scrollable box; a new "Expand summary" toggle grows it to a much taller view (up to ~75% of the window height, ~82% on mobile) so you can read a long changelog without scrolling inside a cramped panel, then collapse it back. Thanks @nankingjing. (#5209, #4705)
- **Export a conversation to a self-contained, theme-matched HTML file.** From a session you can now download a single standalone `.html` of the transcript with the current theme's palette baked in — no external resource loads (fully offline/portable), messages rendered as escaped text (no script/style/raw-HTML injection from conversation content), and remote images neutralized. Thanks @brick-hard. (#4968)
### Fixed
+7 -2
View File
@@ -400,8 +400,13 @@
<span id="updateMsg"></span>
<div id="updateWhatsNewLinks" style="display:none;font-size:11px;margin-left:8px;white-space:nowrap"></div>
<div id="updateSummaryPanel" style="display:none;font-size:12px;line-height:1.45;margin-top:6px;padding:8px 10px;border:1px solid var(--border2);border-radius:8px;background:rgba(255,255,255,.04);max-width:720px;white-space:normal">
<div id="updateSummaryText"></div>
<div id="updateSummaryDiffLinks" style="display:none;font-size:11px;margin-top:8px"></div>
<div id="updateSummaryToolbar" style="display:none;justify-content:flex-end;margin-bottom:6px">
<button type="button" id="btnUpdateSummaryExpand" class="linklike update-summary-toggle" aria-expanded="false" onclick="toggleUpdateSummaryExpanded()">Expand summary</button>
</div>
<div id="updateSummaryScroll">
<div id="updateSummaryText"></div>
<div id="updateSummaryDiffLinks" style="display:none;font-size:11px;margin-top:8px"></div>
</div>
</div>
<div id="updateError" style="display:none;font-size:12px;color:var(--error,#e05);margin-top:4px;word-break:break-word"></div>
</div>
+11 -3
View File
@@ -4439,12 +4439,20 @@ async function _runRenderSessionListRefresh(opts, _gen){
try{
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
const sessionListQS = _sessionListQueryString();
const sessionRequestOpts={timeoutToast:false};
// #5394: the sidebar session-list GET is idempotent, so 502/503/504 retry
// must be unconditional. Previously retries/retryStatuses were boot-gated, so
// a transient 502 during an nginx->backend restart on a warm refresh (profile
// switch, focus/visible/reconnect) failed on the first attempt and left the
// sidebar stale until a hard reload. Boot still keeps the larger timeout +
// timeout retry; every refresh now retries the transient upstream statuses.
const sessionRequestOpts={
timeoutToast:false,
retries:1,
retryStatuses:[502,503,504],
};
if(!_sessionListHasLoadedOnce){
sessionRequestOpts.timeoutMs=_SESSION_LIST_BOOT_TIMEOUT_MS;
sessionRequestOpts.retries=1;
sessionRequestOpts.retryTimeouts=true;
sessionRequestOpts.retryStatuses=[502,503,504];
}
const {sessData, projData}=await _loadSidebarSessionListPayload(sessionListQS, sessionRequestOpts);
// Discard stale response — a newer renderSessionList() call superseded us.
+5 -2
View File
@@ -1931,8 +1931,11 @@
/* ── Update banner ── */
.update-banner{display:none;background:var(--surface);border:1px solid var(--accent);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;width:calc(100% - 24px);box-sizing:border-box;font-size:13px;color:var(--accent-text);align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;overflow-wrap:anywhere;}
.update-banner.visible{display:flex;}
#updateMsg,#updateWhatsNewLinks,#updateSummaryPanel,#updateSummaryDiffLinks{max-width:100%;overflow-wrap:anywhere;word-break:break-word;}
#updateSummaryPanel{max-height:min(34vh,260px);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;scrollbar-width:thin;scrollbar-color:var(--accent) transparent;}
#updateMsg,#updateWhatsNewLinks,#updateSummaryPanel,#updateSummaryScroll,#updateSummaryDiffLinks{max-width:100%;overflow-wrap:anywhere;word-break:break-word;}
#updateSummaryScroll{max-height:min(34vh,260px);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;scrollbar-width:thin;scrollbar-color:var(--accent) transparent;}
#updateSummaryPanel.update-summary-expanded #updateSummaryScroll{max-height:min(75vh,560px);}
@media (max-width:600px){#updateSummaryPanel.update-summary-expanded #updateSummaryScroll{max-height:min(82vh,640px);}}
.update-summary-toggle{font-size:11px;color:var(--accent);text-decoration:underline;background:none;border:0;padding:0;cursor:pointer;}
#updateWhatsNewLinks{white-space:normal!important;margin-left:0!important;}
.update-banner>div:last-child{margin-left:auto;}
@media (max-width:600px){.update-banner{width:calc(100% - 16px);padding:10px 12px;gap:10px;}.update-banner>div:last-child{width:100%;justify-content:flex-end;}}
+24 -1
View File
@@ -8234,10 +8234,29 @@ function _hideUpdateSummaryPanel(){
const panel=$('updateSummaryPanel');
const text=$('updateSummaryText');
const links=$('updateSummaryDiffLinks');
if(panel) panel.style.display='none';
const toolbar=$('updateSummaryToolbar');
if(panel){
panel.style.display='none';
panel.classList.remove('update-summary-expanded');
}
if(toolbar) toolbar.style.display='none';
_syncUpdateSummaryExpandButton(false);
if(text) text.textContent='';
if(links){links.replaceChildren();links.style.display='none';}
}
function _syncUpdateSummaryExpandButton(expanded){
const btn=$('btnUpdateSummaryExpand');
if(!btn) return;
btn.setAttribute('aria-expanded',expanded?'true':'false');
btn.textContent=expanded?'Collapse summary':'Expand summary';
}
function toggleUpdateSummaryExpanded(){
const panel=$('updateSummaryPanel');
if(!panel||panel.style.display==='none') return;
const expanded=!panel.classList.contains('update-summary-expanded');
panel.classList.toggle('update-summary-expanded',expanded);
_syncUpdateSummaryExpandButton(expanded);
}
const WHATS_NEW_SUMMARY_STORAGE_KEY='hermes-whats-new-generated-summaries';
function _loadStoredUpdateSummaries(){
window._whatsNewGeneratedSummaries=window._whatsNewGeneratedSummaries||{};
@@ -8290,8 +8309,12 @@ function _renderUpdateSummaryPanel(payload,data,targetKey){
const panel=$('updateSummaryPanel');
const text=$('updateSummaryText');
const links=$('updateSummaryDiffLinks');
const toolbar=$('updateSummaryToolbar');
if(!panel||!text) return;
panel.style.display='block';
panel.classList.remove('update-summary-expanded');
_syncUpdateSummaryExpandButton(false);
if(toolbar) toolbar.style.display='flex';
const sections=Array.isArray(payload&&payload.summary_sections)?payload.summary_sections:null;
text.replaceChildren();
if(sections&&sections.length){
@@ -0,0 +1,40 @@
"""Static-analysis tests for #4705 (update summary panel expand control)."""
from pathlib import Path
ROOT = Path(__file__).parent.parent
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
class TestIssue4705UpdateSummaryExpand:
def test_summary_panel_has_expand_control(self):
assert 'id="updateSummaryToolbar"' in INDEX_HTML
assert 'id="btnUpdateSummaryExpand"' in INDEX_HTML
assert 'id="updateSummaryScroll"' in INDEX_HTML
assert 'onclick="toggleUpdateSummaryExpanded()"' in INDEX_HTML
def test_toggle_function_toggles_expanded_class(self):
assert "function toggleUpdateSummaryExpanded()" in UI_JS
assert "update-summary-expanded" in UI_JS
assert "_syncUpdateSummaryExpandButton" in UI_JS
def test_render_shows_toolbar_and_resets_collapsed(self):
idx = UI_JS.find("function _renderUpdateSummaryPanel(")
assert idx >= 0
body = UI_JS[idx:idx + 1200]
assert "updateSummaryToolbar" in body
assert "classList.remove('update-summary-expanded')" in body
assert "_syncUpdateSummaryExpandButton(false)" in body
def test_hide_clears_expanded_state(self):
idx = UI_JS.find("function _hideUpdateSummaryPanel(")
assert idx >= 0
body = UI_JS[idx:idx + 700]
assert "classList.remove('update-summary-expanded')" in body
assert "_syncUpdateSummaryExpandButton(false)" in body
def test_expanded_css_uses_larger_viewport_height(self):
assert "#updateSummaryScroll{max-height:min(34vh,260px)" in STYLE_CSS
assert "#updateSummaryPanel.update-summary-expanded #updateSummaryScroll{max-height:min(75vh,560px);}" in STYLE_CSS
assert "@media (max-width:600px){#updateSummaryPanel.update-summary-expanded #updateSummaryScroll{max-height:min(82vh,640px);}}" in STYLE_CSS
+1 -1
View File
@@ -24,7 +24,7 @@ def test_session_list_refreshes_are_coalesced_while_in_flight():
assert "_renderSessionListQueuedRequest={" in src
assert "opts:_mergeRenderSessionListOptions" in src
assert "if (_gen !== _renderSessionListGen) return" in src
assert "const sessionRequestOpts={timeoutToast:false};" in src
assert "const sessionRequestOpts={" in src
assert "api('/api/sessions' + sessionListQS,sessionRequestOpts)" in src
assert "api('/api/projects' + projectQS,{timeoutToast:false})" in src
+28 -6
View File
@@ -56,22 +56,44 @@ def test_sessions_and_projects_load_independently_so_projects_failure_cannot_bla
assert "_applySessionListPayload(sessData,projData)" in block
def test_sessions_api_uses_longer_timeout_and_timeout_retry_for_boot_refresh_only():
def test_sessions_api_always_retries_transient_upstream_statuses_and_boot_keeps_longer_timeout():
# #5394: the sidebar session-list GET is idempotent, so retries + retryStatuses
# (502/503/504) must be set on EVERY refresh — not gated to cold boot — so a
# transient 502 during an nginx->backend restart on a warm refresh (profile
# switch, focus/visible/reconnect) is retried instead of leaving the sidebar
# stale. The larger boot timeout + timeout retry stay boot-only.
sessions_src = _sessions_js()
workspace_src = _workspace_js()
refresh_start = sessions_src.find("async function _runRenderSessionListRefresh")
assert refresh_start > 0
refresh_end = sessions_src.find("async function _loadSidebarSessionListPayload", refresh_start)
assert refresh_end > refresh_start
refresh = sessions_src[refresh_start:refresh_end]
helper_start = sessions_src.find("async function _loadSidebarSessionListPayload")
assert helper_start > 0
helper_end = sessions_src.find("async function _drainRenderSessionListQueue", helper_start)
assert helper_end > helper_start
helper = sessions_src[helper_start:helper_end]
assert "const sessionRequestOpts={timeoutToast:false};" in sessions_src
assert "if(!_sessionListHasLoadedOnce){" in sessions_src
assert "sessionRequestOpts.timeoutMs=_SESSION_LIST_BOOT_TIMEOUT_MS;" in sessions_src
# Always-on retry options live in the base opts object.
assert "const sessionRequestOpts={" in refresh
assert "retries:1," in refresh
assert "retryStatuses:[502,503,504]," in refresh
boot_gate = refresh.find("if(!_sessionListHasLoadedOnce){")
assert boot_gate > 0
# The retry options are declared BEFORE the boot-only gate (i.e. unconditional).
assert refresh.index("retries:1,") < boot_gate
assert refresh.index("retryStatuses:[502,503,504],") < boot_gate
# Boot-only path still carries the larger timeout + timeout retry.
assert "sessionRequestOpts.timeoutMs=_SESSION_LIST_BOOT_TIMEOUT_MS;" in refresh
assert refresh.index("sessionRequestOpts.timeoutMs=_SESSION_LIST_BOOT_TIMEOUT_MS;") > boot_gate
assert "sessionRequestOpts.retryTimeouts=true;" in refresh
assert refresh.index("sessionRequestOpts.retryTimeouts=true;") > boot_gate
assert "const sessData = await api('/api/sessions' + sessionListQS,sessionRequestOpts);" in helper
assert "api('/api/sessions' + sessionListQS,{timeoutToast:false})" not in helper
assert "sessionRequestOpts.retryTimeouts=true" in sessions_src
assert "sessionRequestOpts.retryStatuses=[502,503,504]" in sessions_src
assert "retryTimeouts" in workspace_src
assert "retryStatuses" in workspace_src
+2 -1
View File
@@ -2120,7 +2120,8 @@ class TestWhatsNewSummaryToggle:
def test_update_summary_panel_is_scrollable_for_long_summaries(self):
style = read('static/style.css')
assert '#updateSummaryPanel{max-height:min(34vh,260px);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;scrollbar-width:thin;scrollbar-color:var(--accent) transparent;}' in style
assert '#updateSummaryScroll{max-height:min(34vh,260px);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;scrollbar-width:thin;scrollbar-color:var(--accent) transparent;}' in style
assert '#updateSummaryPanel.update-summary-expanded #updateSummaryScroll{max-height:min(75vh,560px);}' in style
def test_update_summary_many_updates_caps_commit_input_and_discloses_scope(self, monkeypatch):
import api.updates as upd