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:
9 files changed
+118
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -400,9 +400,14 @@
|
||||
<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="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>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
|
||||
|
||||
+11
-3
@@ -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
@@ -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
@@ -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){
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user