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){
|
if(streamId){
|
||||||
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
|
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
|
||||||
if(st.active){
|
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}));
|
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}${_runJournalReplayParams()}`,document.baseURI||location.href).href,{withCredentials:true}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6648,9 +6648,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Show as a small inline notice, not a full error
|
// Show as a small inline notice, not a full error
|
||||||
setComposerStatus(`${d.message||'Warning'}`);
|
setComposerStatus(`${d.message||'Warning'}`,d.type==='fallback'?4000:undefined);
|
||||||
// If it's a fallback notice, show it briefly then clear
|
|
||||||
if(d.type==='fallback') setTimeout(()=>setComposerStatus(''),4000);
|
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6699,7 +6697,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
try{
|
try{
|
||||||
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
|
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
|
||||||
if(st&&st.active){
|
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}));
|
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}${_runJournalReplayParams()}`,document.baseURI||location.href).href,{withCredentials:true}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -7960,7 +7960,13 @@ function setStatus(t){
|
|||||||
showToast(t, 4000);
|
showToast(t, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setComposerStatus(t){
|
let _composerStatusTimer=null;
|
||||||
|
|
||||||
|
function setComposerStatus(t,timeoutMs){
|
||||||
|
if(_composerStatusTimer!==null){
|
||||||
|
clearTimeout(_composerStatusTimer);
|
||||||
|
_composerStatusTimer=null;
|
||||||
|
}
|
||||||
const el=$('composerStatus');
|
const el=$('composerStatus');
|
||||||
if(!el)return;
|
if(!el)return;
|
||||||
const statusHidden=!!(window._composerControlVisibility&&window._composerControlVisibility.hide_composer_status);
|
const statusHidden=!!(window._composerControlVisibility&&window._composerControlVisibility.hide_composer_status);
|
||||||
@@ -7979,6 +7985,14 @@ function setComposerStatus(t){
|
|||||||
el.removeAttribute('aria-hidden');
|
el.removeAttribute('aria-hidden');
|
||||||
el.textContent=t;
|
el.textContent=t;
|
||||||
el.style.display='';
|
el.style.display='';
|
||||||
|
if(timeoutMs>0){
|
||||||
|
const timer=setTimeout(()=>{
|
||||||
|
if(_composerStatusTimer!==timer)return;
|
||||||
|
_composerStatusTimer=null;
|
||||||
|
setComposerStatus('');
|
||||||
|
},timeoutMs);
|
||||||
|
_composerStatusTimer=timer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _composerLockState=null;
|
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 "Gateway offline" in MESSAGES_JS
|
||||||
assert "d.type==='approval_gateway_unsupported'" in MESSAGES_JS
|
assert "d.type==='approval_gateway_unsupported'" in MESSAGES_JS
|
||||||
assert "Approvals not supported" 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():
|
def test_gateway_chat_source_mentions_offline_warning_type():
|
||||||
|
|||||||
Reference in new issue
Block a user