diff --git a/static/messages.js b/static/messages.js index 261582ec..e24f90d1 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2613,7 +2613,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(streamId){ const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`); if(st.active){ - setComposerStatus('Reconnected'); + setComposerStatus('Reconnected',1000); _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}${_runJournalReplayParams()}`,document.baseURI||location.href).href,{withCredentials:true})); return; } @@ -6648,9 +6648,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return; } // Show as a small inline notice, not a full error - setComposerStatus(`${d.message||'Warning'}`); - // If it's a fallback notice, show it briefly then clear - if(d.type==='fallback') setTimeout(()=>setComposerStatus(''),4000); + setComposerStatus(`${d.message||'Warning'}`,d.type==='fallback'?4000:undefined); }catch(_){} }); @@ -6699,7 +6697,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ try{ const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`); if(st&&st.active){ - setComposerStatus('Reconnected'); + setComposerStatus('Reconnected',1000); _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}${_runJournalReplayParams()}`,document.baseURI||location.href).href,{withCredentials:true})); return; } diff --git a/static/ui.js b/static/ui.js index c1b8b57e..f98cb2df 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7960,7 +7960,13 @@ function setStatus(t){ showToast(t, 4000); } -function setComposerStatus(t){ +let _composerStatusTimer=null; + +function setComposerStatus(t,timeoutMs){ + if(_composerStatusTimer!==null){ + clearTimeout(_composerStatusTimer); + _composerStatusTimer=null; + } const el=$('composerStatus'); if(!el)return; const statusHidden=!!(window._composerControlVisibility&&window._composerControlVisibility.hide_composer_status); @@ -7979,6 +7985,14 @@ function setComposerStatus(t){ el.removeAttribute('aria-hidden'); el.textContent=t; el.style.display=''; + if(timeoutMs>0){ + const timer=setTimeout(()=>{ + if(_composerStatusTimer!==timer)return; + _composerStatusTimer=null; + setComposerStatus(''); + },timeoutMs); + _composerStatusTimer=timer; + } } let _composerLockState=null; diff --git a/tests/test_composer_status_timeout.py b/tests/test_composer_status_timeout.py new file mode 100644 index 00000000..961f80a9 --- /dev/null +++ b/tests/test_composer_status_timeout.py @@ -0,0 +1,147 @@ +import json +import re +import subprocess +import textwrap +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") + + +def _function_source(source: str, name: str) -> str: + start = source.index(f"function {name}(") + brace = source.index("{", start) + depth = 0 + for index in range(brace, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[start : index + 1] + raise AssertionError(f"could not extract {name}") + + +def _composer_status_source(source: str) -> str: + owner = "let _composerStatusTimer=null;" + owner_start = source.index(owner) + function_start = source.index("function setComposerStatus(", owner_start) + function_source = _function_source(source, "setComposerStatus") + assert owner_start < function_start + return source[owner_start : function_start + len(function_source)] + + +def test_composer_status_timeout_is_race_safe(): + composer_status_source = _composer_status_source(UI_JS) + script = textwrap.dedent( + f""" + const statusElement = {{ + style: {{display: 'none'}}, + textContent: '', + classList: {{remove() {{}}}}, + removeAttribute() {{}}, + }}; + globalThis.window = {{_composerControlVisibility: {{}}}}; + globalThis.$ = (id) => id === 'composerStatus' ? statusElement : null; + + let now = 0; + let nextTimerId = 1; + const timers = new Map(); + globalThis.setTimeout = (fn, delay) => {{ + const id = nextTimerId++; + timers.set(id, {{fn, due: now + delay}}); + return id; + }}; + globalThis.clearTimeout = (id) => timers.delete(id); + function advance(ms) {{ + const target = now + ms; + while (true) {{ + let next = null; + for (const [id, timer] of timers) {{ + if (timer.due <= target && (!next || timer.due < next.timer.due)) {{ + next = {{id, timer}}; + }} + }} + if (!next) break; + timers.delete(next.id); + now = next.timer.due; + next.timer.fn(); + }} + now = target; + }} + function visible() {{ + return statusElement.style.display !== 'none' && statusElement.textContent; + }} + function expect(condition, message) {{ + if (!condition) throw new Error(message); + }} + + {composer_status_source} + + setComposerStatus('Reconnected', 1000); + expect(visible() === 'Reconnected', 'timed status should be visible immediately'); + advance(999); + expect(visible() === 'Reconnected', 'timed status should remain visible before expiry'); + advance(1); + expect(!visible(), 'timed status should hide at expiry'); + + setComposerStatus('Old status', 1000); + advance(500); + setComposerStatus('New status'); + advance(500); + expect(visible() === 'New status', 'old timer must not clear a newer untimed status'); + + setComposerStatus('Same status', 1000); + advance(500); + setComposerStatus('Same status', 1000); + advance(500); + expect(visible() === 'Same status', 'repeated timed status needs a fresh timeout'); + advance(500); + expect(!visible(), 'repeated timed status should hide after its fresh timeout'); + + setComposerStatus('Untimed status'); + advance(5000); + expect(visible() === 'Untimed status', 'untimed status must remain visible'); + + setComposerStatus('Clear me', 1000); + setComposerStatus(''); + expect(!visible(), 'explicit clear must hide immediately'); + advance(1000); + expect(!visible(), 'explicit clear must cancel the owned timer'); + + setComposerStatus('Hide me', 1000); + window._composerControlVisibility.hide_composer_status = true; + setComposerStatus('Still hidden'); + expect(!visible(), 'hidden-status preference must hide the status'); + advance(1000); + expect(!visible(), 'hidden-status preference must cancel timed expiry'); + window._composerControlVisibility.hide_composer_status = false; + + process.stdout.write(JSON.stringify({{ok: true}})); + """ + ) + completed = subprocess.run( + ["node", "-e", script], + check=True, + capture_output=True, + text=True, + ) + assert json.loads(completed.stdout) == {"ok": True} + + +def test_messages_wires_all_timed_composer_statuses_through_shared_owner(): + reconnected_calls = re.findall( + r"setComposerStatus\('Reconnected'(?:,[^)]*)?\);", MESSAGES_JS + ) + assert reconnected_calls == [ + "setComposerStatus('Reconnected',1000);", + "setComposerStatus('Reconnected',1000);", + ] + assert "setComposerStatus('Reconnected');" not in MESSAGES_JS + assert ( + "setComposerStatus(`${d.message||'Warning'}`,d.type==='fallback'?4000:undefined);" + in MESSAGES_JS + ) + assert "setTimeout(()=>setComposerStatus(''),4000)" not in MESSAGES_JS diff --git a/tests/test_issue5139_gateway_approval_offline_notice.py b/tests/test_issue5139_gateway_approval_offline_notice.py index 94a2cf89..1c6ca173 100644 --- a/tests/test_issue5139_gateway_approval_offline_notice.py +++ b/tests/test_issue5139_gateway_approval_offline_notice.py @@ -246,7 +246,7 @@ def test_messages_js_handles_offline_warning_without_touching_unsupported_branch assert "Gateway offline" in MESSAGES_JS assert "d.type==='approval_gateway_unsupported'" in MESSAGES_JS assert "Approvals not supported" in MESSAGES_JS - assert "setComposerStatus(`${d.message||'Warning'}`);" in MESSAGES_JS + assert "setComposerStatus(`${d.message||'Warning'}`,d.type==='fallback'?4000:undefined);" in MESSAGES_JS def test_gateway_chat_source_mentions_offline_warning_type():