diff --git a/CHANGELOG.md b/CHANGELOG.md index 4891c651..8584f2d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/index.html b/static/index.html index 6602dc76..3ec6114b 100644 --- a/static/index.html +++ b/static/index.html @@ -400,8 +400,13 @@ diff --git a/static/sessions.js b/static/sessions.js index f2543e3a..bde3a1c4 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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. diff --git a/static/style.css b/static/style.css index 721540a2..410e2f2c 100644 --- a/static/style.css +++ b/static/style.css @@ -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;}} diff --git a/static/ui.js b/static/ui.js index 67fe3af1..d7d385bd 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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&§ions.length){ diff --git a/tests/test_issue4705_update_summary_expand.py b/tests/test_issue4705_update_summary_expand.py new file mode 100644 index 00000000..d4111647 --- /dev/null +++ b/tests/test_issue4705_update_summary_expand.py @@ -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 diff --git a/tests/test_performance_polling_hardening.py b/tests/test_performance_polling_hardening.py index 23f072d9..61851288 100644 --- a/tests/test_performance_polling_hardening.py +++ b/tests/test_performance_polling_hardening.py @@ -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 diff --git a/tests/test_session_sidebar_resilience.py b/tests/test_session_sidebar_resilience.py index f80d528e..7c4caa62 100644 --- a/tests/test_session_sidebar_resilience.py +++ b/tests/test_session_sidebar_resilience.py @@ -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 diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 73df0cc4..b89c04d4 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -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