Stage 397: PR #2671 — fix: SSE reconnect jitter + cron run row inline expansion

Closes #2629
Closes #2661

Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-05-21 17:13:59 +00:00
parent 92f1896754
commit 12a92dd50a
4 changed files with 58 additions and 6 deletions

View File

@@ -3,6 +3,11 @@
## [Unreleased]
### Changed
- Session-list SSE reconnects now use bounded jitter/backoff instead of a fixed 5-second retry, reducing reconnect bursts after restarts or network drops.
- Expanded cron run rows now render the full output inline immediately; the truncated preview remains only for collapsed rows, and the full-output fallback no longer drops content when Markdown rendering is unavailable.
## [v0.51.103] — 2026-05-21 — Release CA (stage-396 — 1-PR follow-on — Settings → Plugins distinguishes exclusive/provider activation)

View File

@@ -658,11 +658,14 @@ async function _loadRunContent(jobId, filename, runId){
body.textContent = data.error;
return;
}
const expanded = _cronExpansionGet(_cronRunExpandKey(jobId, filename));
const output = expanded ? (data.content || data.snippet || '') : (data.snippet || data.content || '');
body.classList.toggle('expanded', expanded);
// Render markdown content using the same renderer as chat messages
if (typeof renderMd === 'function') {
body.innerHTML = renderMd(data.snippet || data.content);
body.innerHTML = renderMd(output);
} else {
body.textContent = data.snippet || data.content;
body.textContent = output;
}
const usageStrip = _formatCronRunUsageStrip(data.usage);
if (usageStrip) {
@@ -671,13 +674,15 @@ async function _loadRunContent(jobId, filename, runId){
usage.textContent = usageStrip;
body.appendChild(usage);
}
// Show "View full output" button if content was truncated
if (data.content && data.snippet && data.content.length > data.snippet.length) {
// Show "View full output" button only for collapsed previews. Expanded rows render the full body inline.
if (!expanded && data.content && data.snippet && data.content.length > data.snippet.length) {
const btn = document.createElement('button');
btn.style.cssText = 'margin-top:8px;padding:4px 12px;border-radius:var(--radius-btn);border:1px solid var(--border-subtle);background:var(--surface-subtle);color:var(--text-secondary);cursor:pointer;font-size:12px';
btn.textContent = t('cron_view_full_output') || 'View full output';
btn.onclick = () => {
body.innerHTML = renderMd ? renderMd(data.content) : '';
_cronExpansionSet(_cronRunExpandKey(jobId, filename), true);
body.classList.add('expanded');
body.innerHTML = renderMd ? renderMd(data.content) : data.content;
btn.remove();
};
body.appendChild(btn);

View File

@@ -2112,6 +2112,16 @@ let _sessionEventsSSE = null;
let _sessionEventsRefreshTimer = 0;
let _sessionEventsReconnectTimer = 0;
let _sessionEventsNeedsRefreshOnOpen = false;
let _sessionEventsReconnectAttempt = 0;
const _sessionEventsReconnectBaseMs = 5000;
const _sessionEventsReconnectMaxMs = 30000;
function _sessionEventsReconnectDelayMs(){
const attempt = Math.max(0, Number(_sessionEventsReconnectAttempt || 0));
const base = Math.min(_sessionEventsReconnectMaxMs, _sessionEventsReconnectBaseMs * Math.pow(2, attempt));
const jitter = Math.floor(Math.random() * Math.max(1, Math.floor(base * 0.35)));
return Math.min(_sessionEventsReconnectMaxMs, Math.floor(base * 0.75) + jitter);
}
let _sessionListRefreshInFlight = false;
let _sessionListRefreshPendingReason = '';
@@ -2233,6 +2243,7 @@ function ensureSessionEventsSSE(){
// Same-origin relative URL preserves subpath mounts and normal WebUI cookies.
_sessionEventsSSE = new EventSource('api/sessions/events');
_sessionEventsSSE.onopen = () => {
_sessionEventsReconnectAttempt = 0;
if(!_sessionEventsNeedsRefreshOnOpen) return;
_sessionEventsNeedsRefreshOnOpen = false;
void refreshSessionList('reconnect');
@@ -2244,10 +2255,12 @@ function ensureSessionEventsSSE(){
_sessionEventsNeedsRefreshOnOpen = true;
_closeSessionEventsSSE();
if(_sessionEventsReconnectTimer) return;
const delayMs = _sessionEventsReconnectDelayMs();
_sessionEventsReconnectAttempt = Math.min(_sessionEventsReconnectAttempt + 1, 6);
_sessionEventsReconnectTimer = setTimeout(() => {
_sessionEventsReconnectTimer = 0;
ensureSessionEventsSSE();
}, 5000);
}, delayMs);
};
}catch(e){
_closeSessionEventsSSE();

View File

@@ -0,0 +1,29 @@
from pathlib import Path
SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8")
PANELS_JS = Path("static/panels.js").read_text(encoding="utf-8")
CHANGELOG = Path("CHANGELOG.md").read_text(encoding="utf-8")
def test_session_events_reconnect_uses_jittered_backoff_not_fixed_delay():
assert "function _sessionEventsReconnectDelayMs()" in SESSIONS_JS
assert "Math.random()" in SESSIONS_JS
assert "_sessionEventsReconnectMaxMs" in SESSIONS_JS
assert "_sessionEventsReconnectAttempt = 0" in SESSIONS_JS
ensure_fn = SESSIONS_JS[SESSIONS_JS.find("function ensureSessionEventsSSE()") :]
assert "const delayMs = _sessionEventsReconnectDelayMs();" in ensure_fn
assert "}, 5000);" not in ensure_fn
def test_cron_expanded_run_renders_full_content_inline():
assert "const expanded = _cronExpansionGet(_cronRunExpandKey(jobId, filename));" in PANELS_JS
assert "const output = expanded ? (data.content || data.snippet || '') : (data.snippet || data.content || '');" in PANELS_JS
assert "if (!expanded && data.content && data.snippet && data.content.length > data.snippet.length)" in PANELS_JS
assert "_cronExpansionSet(_cronRunExpandKey(jobId, filename), true);" in PANELS_JS
def test_changelog_mentions_session_and_cron_polish():
unreleased = CHANGELOG.split("## [v0.51.103]", 1)[0]
assert "bounded jitter/backoff" in unreleased
assert "Expanded cron run rows" in unreleased
assert "no longer drops content when Markdown rendering is unavailable" in unreleased