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:
starship-sandnesquena-hermes authored and GitHub committed 2026-08-14 18:27:00 -07:00
1 parent 1c4ea9c99a
commit 108841ba31
4 files changed
+166 -7

No files matched your search

+3 -5
View File
@@ -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
View File
@@ -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;
+147
View File
@@ -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():