fix(composer): dismiss reconnected status (#7028)
Keep reconnect confirmations transient so they stop consuming mobile composer space, while a single owned timer prevents stale notices from clearing newer status text. Assisted-by: Hermes Agent:gpt-5.6-sol Assisted-by: Codex:gpt-5.6-luna Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
This commit is contained in:
1 parent
1c4ea9c99a
commit
108841ba31
4 files changed
+166
-7
No files matched your search
+3
-5
@@ -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;
|
||||
}
|
||||
|
||||
+15
-1
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user