diff --git a/static/sessions.js b/static/sessions.js index 48d0701b..3fd49248 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4251,6 +4251,17 @@ function _invalidateSessionListRenders(){ _renderSessionListGen++; _pendingSessionListPayload = null; _renderSessionListQueuedRequest = null; + // A retry whose fetch is invalidated here (e.g. a profile switch mid-retry) + // would otherwise leave the error note stuck as an inert "Retrying…" button + // with no request in flight — the stale fetch returns before + // _showSessionListLoadError and the .finally() bails when the old button was + // removed. Clear the pending retry markers so the next repaint shows an + // actionable idle Retry again. + if(_sessionListLoadError && (_sessionListLoadError.retrying || _sessionListLoadError._retryFailedFocus)){ + _sessionListLoadError = {..._sessionListLoadError}; + delete _sessionListLoadError.retrying; + delete _sessionListLoadError._retryFailedFocus; + } } if(typeof window!=='undefined') window._invalidateSessionListRenders = _invalidateSessionListRenders; diff --git a/tests/test_issue5501_session_list_retry_affordance.py b/tests/test_issue5501_session_list_retry_affordance.py index e5b4c9cb..ea0c0cc8 100644 --- a/tests/test_issue5501_session_list_retry_affordance.py +++ b/tests/test_issue5501_session_list_retry_affordance.py @@ -39,6 +39,7 @@ def _build_node_script(extra_js): src = SESSIONS_JS.read_text(encoding="utf-8") show_error_fn = _extract_function(src, "_showSessionListLoadError") retry_note_fn = _extract_function(src, "_renderSessionListLoadErrorNote") + invalidate_fn = _extract_function(src, "_invalidateSessionListRenders") return f""" class FakeClassList {{ constructor(node) {{ @@ -146,6 +147,9 @@ global.document = {{ createElement: (tag) => new FakeElement(tag) }}; global.$ = (id) => id === 'sessionList' ? list : null; global._allSessions = []; global._sessionListLoadError = null; +global._renderSessionListGen = 0; +global._pendingSessionListPayload = null; +global._renderSessionListQueuedRequest = null; global._sessionListFromCacheCalls = []; global.renderSessionListCalls = []; global.renderSessionListFromCache = () => {{ @@ -184,6 +188,7 @@ global.renderSessionList = (opts) => {{ }}; {show_error_fn} {retry_note_fn} +{invalidate_fn} {extra_js} """ @@ -579,3 +584,38 @@ def test_session_list_error_note_has_live_region_and_pending_uses_aria_disabled( assert "Retrying..." not in note_fn # keyboard focus restored on a failed retry assert "_retryFailedFocus" in note_fn + + +def test_retry_pending_state_cleared_on_render_invalidation(): + """#5505 gate (Codex SILENT): a retry whose fetch is invalidated mid-flight + (e.g. a profile switch) must not leave the button stuck as an inert + 'Retrying…' with no request in flight. _invalidateSessionListRenders() clears + the pending retry markers so the next repaint shows an actionable idle Retry.""" + if NODE is None: + pytest.skip("node not on PATH") + + script = _render_script( + """ +_showSessionListLoadError(new Error('backend busy')); +global._sessionListLoadError = {...global._sessionListLoadError, retrying: true, _retryFailedFocus: true}; +_invalidateSessionListRenders(); +global.renderSessionListFromCache(); +const note = list.children[0]; +const retry = findButton(note); +console.log(JSON.stringify({ + retryingCleared: !global._sessionListLoadError.retrying, + focusFlagCleared: !global._sessionListLoadError._retryFailedFocus, + text: retry.textContent, + ariaDisabled: retry.getAttribute('aria-disabled'), + clickable: typeof retry.onclick === 'function', +})); +""" + ) + body = _run_node(script) + assert body == { + "retryingCleared": True, + "focusFlagCleared": True, + "text": "Retry", + "ariaDisabled": None, + "clickable": True, + }