From 2752e23b84d07101a96e115303a249f2d1bd72a3 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:13:46 +0000 Subject: [PATCH 01/10] =?UTF-8?q?Stage=20397:=20PR=20#2690=20=E2=80=94=20i?= =?UTF-8?q?18n:=20correct=20zh-CN/zh-TW=20cron=5Fstatus=5Factive=20transla?= =?UTF-8?q?tions=20(=E8=BF=90=E8=A1=8C=E4=B8=AD=E2=86=92=E5=B7=B2=E5=90=AF?= =?UTF-8?q?=E7=94=A8=20/=20=E6=B4=BB=E8=BA=8D=E4=B8=AD=E2=86=92=E5=B7=B2?= =?UTF-8?q?=E5=95=9F=E7=94=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: laiaman --- static/i18n.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/i18n.js b/static/i18n.js index 98c9928c..980c6c5b 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -7914,7 +7914,7 @@ const LOCALES = { cron_status_off: '关闭', cron_status_paused: '暂停', cron_status_error: '错误', - cron_status_active: '运行中', + cron_status_active: '已启用', cron_status_running: '执行中\u2026', cron_status_needs_attention: '需要处理', cron_attention_desc: '这个重复定时任务没有下次运行时间。调度器可能没能计算出下一次运行。', @@ -9274,7 +9274,7 @@ const LOCALES = { cron_schedule_placeholder: '\u6392\u7a0b', cron_schedule_required: '\u9700\u8981\u6392\u7a0b', cron_schedule_required_example: '\u9700\u8981\u6392\u7a0b\uff08\u4f8b\u5982 "0 9 * * *" \u6216 "every 1h"\uff09', - cron_status_active: '\u6d3b\u8e8d\u4e2d', + cron_status_active: '已啟用', cron_status_running: '\u57f7\u884c\u4e2d\u2026', cron_status_error: '\u932f\u8aa4', cron_status_off: '\u672a\u555f\u7528', From 958762bcfe74723e2484afc883c908109d456660 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:13:49 +0000 Subject: [PATCH 02/10] =?UTF-8?q?Stage=20397:=20PR=20#2701=20=E2=80=94=20f?= =?UTF-8?q?ix:=20geist-contrast=20skin=20composer=20UI=20improvements=20(l?= =?UTF-8?q?ight-mode=20text,=20scrollbar,=20send=20button=20color)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: jasonjcwu --- static/style.css | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/static/style.css b/static/style.css index 9ddc7cc0..d7c62b64 100644 --- a/static/style.css +++ b/static/style.css @@ -347,7 +347,7 @@ :root[data-skin="geist-contrast"] .tool-card, :root[data-skin="geist-contrast"] .msg-body pre, :root[data-skin="geist-contrast"] .preview-md pre, - :root[data-skin="geist-contrast"] .composer-box{background:var(--surface)!important;border-color:var(--border)!important;box-shadow:none!important;} + :root[data-skin="geist-contrast"] .composer-box{background:var(--surface)!important;border:none!important;box-shadow:none!important;} :root[data-skin="geist-contrast"] .session-item, :root[data-skin="geist-contrast"] .nav-tab, :root[data-skin="geist-contrast"] .rail-btn, @@ -440,10 +440,10 @@ :root[data-skin="geist-contrast"] button.send-btn:disabled{background:var(--surface-subtle)!important;border-color:var(--border)!important;color:var(--muted)!important;opacity:1!important;} :root.dark[data-skin="geist-contrast"] button.send-btn:disabled svg, :root.dark[data-skin="geist-contrast"] button.send-btn:disabled [data-lucide]{color:var(--muted)!important;stroke:var(--muted)!important;} + :root[data-skin="geist-contrast"] .composer-box:focus-within{border-color:transparent!important;box-shadow:0 0 0 3px var(--focus-ring)!important;outline:none!important;} :root[data-skin="geist-contrast"] input:focus, :root[data-skin="geist-contrast"] textarea:focus, - :root[data-skin="geist-contrast"] select:focus, - :root[data-skin="geist-contrast"] .composer-box:focus-within, + :root[data-skin="geist-contrast"] select:focus{border-color:var(--accent)!important;box-shadow:none!important;outline:none!important;} :root[data-skin="geist-contrast"] .app-dialog-input:focus, :root[data-skin="geist-contrast"] .sidebar-search input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px var(--focus-ring)!important;outline:none!important;} :root[data-skin="geist-contrast"] .logo, @@ -463,6 +463,12 @@ :root[data-skin="geist-contrast"] .sidebar-date-header.pinned{color:var(--accent-text)!important;} :root[data-skin="geist-contrast"]::-webkit-scrollbar-thumb{background:var(--border2)!important;} :root[data-skin="geist-contrast"] ::selection{background:var(--accent-bg-strong);color:var(--strong);} + /* ── Geist Contrast: composer fixes ── */ + /* Light mode: override white user-bubble-text so textarea text is black */ + :root[data-skin="geist-contrast"]:not(.dark){--user-bubble-text:#111111;} + /* Remove scrollbar from textarea */ + :root[data-skin="geist-contrast"] textarea#msg{scrollbar-width:none;overflow-y:auto;} + :root[data-skin="geist-contrast"] textarea#msg::-webkit-scrollbar{display:none;} /* #594: app-dialog light mode overrides — base styles use hardcoded dark gradients */ :root:not(.dark) .app-dialog{ From 345762cf7022fbded1be2f2a6c274bb2a26a2064 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:13:52 +0000 Subject: [PATCH 03/10] =?UTF-8?q?Stage=20397:=20PR=20#2706=20=E2=80=94=20f?= =?UTF-8?q?ix:=20tablet=20hardware=20keyboard=20Enter=20sends=20(treat=20i?= =?UTF-8?q?Pad=20with=20attached=20keyboard=20like=20desktop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: dobby-d-elf --- static/boot.js | 12 +++++++++--- tests/test_mobile_layout.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/static/boot.js b/static/boot.js index ae17eb2e..ca94570a 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1022,6 +1022,11 @@ let _imeComposing=false; })(); function _isImeEnter(e){return e.isComposing||e.keyCode===229||_imeComposing;} window._isImeEnter=_isImeEnter; +function _isVirtualKeyboardLikelyOpen(){ + const vv=window.visualViewport; + if(!vv||!window.innerHeight)return true; + return window.innerHeight-vv.height>120; +} $('msg').addEventListener('keydown',e=>{ // Autocomplete navigation when dropdown is open const dd=$('cmdDropdown'); @@ -1039,13 +1044,14 @@ $('msg').addEventListener('keydown',e=>{ } } // Send key: respect user preference. - // On touch-primary devices (software keyboard), default to Enter = newline - // since there's no physical Shift key. Users send via the Send button. + // On touch-primary devices with the software keyboard open, default to + // Enter = newline since there's no physical Shift key. Hardware keyboards on + // tablets keep desktop behavior when the viewport is not keyboard-shrunk. // The 'ctrl+enter' setting also uses this behavior (Enter = newline). // Users can override in Settings by explicitly choosing 'enter' mode. if(e.key==='Enter'){ if(_isImeEnter(e)){return;} - const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter'; + const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter'&&_isVirtualKeyboardLikelyOpen(); if(window._sendKey==='ctrl+enter'||_mobileDefault){ if(e.ctrlKey||e.metaKey){e.preventDefault();send();} } else { diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index 46ac1972..46cdc0a7 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -1188,6 +1188,24 @@ def test_mobile_enter_newline_uses_match_media(): "boot.js must use matchMedia('(pointer:coarse)') for mobile detection" +def test_mobile_enter_newline_checks_virtual_keyboard_viewport(): + """Touch devices should only force newline while the software keyboard is likely open.""" + boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + assert "function _isVirtualKeyboardLikelyOpen()" in boot_js, \ + "boot.js must isolate the software-keyboard viewport heuristic" + assert "window.visualViewport" in boot_js and "window.innerHeight-vv.height>120" in boot_js, \ + "software-keyboard detection must compare visualViewport height against window.innerHeight" + assert "&&_isVirtualKeyboardLikelyOpen()" in boot_js, \ + "mobile Enter newline override must not apply when a hardware keyboard leaves the viewport unshrunk" + + +def test_mobile_enter_newline_preserves_legacy_fallback_without_visual_viewport(): + """Browsers without visualViewport should keep the previous touch Enter=newline behavior.""" + boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + assert "if(!vv||!window.innerHeight)return true;" in boot_js, \ + "missing visualViewport support must preserve the legacy touch-primary newline fallback" + + def test_mobile_enter_newline_only_overrides_enter_default(): """Mobile newline override must only apply when _sendKey is the default 'enter'.""" boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8") From 92f1896754e1a3fef5dbbfdf59d85c2e5bdb6846 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:13:56 +0000 Subject: [PATCH 04/10] =?UTF-8?q?Stage=20397:=20PR=20#2684=20=E2=80=94=20f?= =?UTF-8?q?ix:=20repair=20stale=20Codex=20OpenAI=20slash-qualified=20model?= =?UTF-8?q?=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ai-ag2026 --- api/routes.py | 18 +++++++-- ...ue1855_resolve_model_provider_fast_path.py | 38 ++++++++++++++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/api/routes.py b/api/routes.py index 455d5bed..c7f287ac 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1512,7 +1512,12 @@ def _resolve_compatible_session_model_state( # qualifier — qualified strings require the catalog to decide whether # the qualifier matches the active provider (see slow path below). bare_model, explicit_provider = _split_provider_qualified_model(model) - if not explicit_provider: + model_prefix = model.split("/", 1)[0].strip().lower() if "/" in model else "" + stale_codex_openai_slash_id = ( + requested_provider == "openai-codex" + and model_prefix == "openai" + ) + if not explicit_provider and not stale_codex_openai_slash_id: return model, requested_provider, False catalog = get_available_models() @@ -1533,7 +1538,14 @@ def _resolve_compatible_session_model_state( bare_for_context, explicit_provider = _split_provider_qualified_model(model) if requested_provider and not explicit_provider: - return model, requested_provider, False + model_prefix = model.split("/", 1)[0].strip().lower() if "/" in model else "" + stale_codex_openai_slash_id = ( + raw_active_provider == "openai-codex" + and requested_provider == "openai-codex" + and model_prefix == "openai" + ) + if not stale_codex_openai_slash_id: + return model, requested_provider, False if model.startswith("@") and ":" in model: provider_raw = explicit_provider or "" @@ -1643,7 +1655,7 @@ def _resolve_compatible_session_model_state( if ( raw_active_provider == "openai-codex" and model_provider == "openai" - and requested_provider is None + and requested_provider in {None, "openai-codex"} and default_model ): # Persist provider_context = "openai-codex" unconditionally on this diff --git a/tests/test_issue1855_resolve_model_provider_fast_path.py b/tests/test_issue1855_resolve_model_provider_fast_path.py index a7aa86f1..0cbb321d 100644 --- a/tests/test_issue1855_resolve_model_provider_fast_path.py +++ b/tests/test_issue1855_resolve_model_provider_fast_path.py @@ -61,13 +61,12 @@ class TestFastPathInvocation: ) assert result == ("gpt-5.5", "openai-codex", False) - def test_fast_path_with_slash_qualified_model_skips_catalog(self): - """Slash-qualified IDs (openrouter/...) still hit the fast path. + def test_fast_path_with_openrouter_slash_qualified_model_skips_catalog(self): + """OpenRouter slash-qualified IDs still hit the fast path. - The fast path only excludes @provider:model strings, not slash- - qualified ones — those are valid model IDs that the picker emits - for OpenRouter and custom-provider routing, and a stored - model_provider is the authoritative routing decision. + Slash-qualified IDs are valid picker output for OpenRouter and a stored + model_provider is the authoritative routing decision. This remains fast + for explicit OpenRouter selections. """ from api.routes import _resolve_compatible_session_model_state @@ -80,6 +79,33 @@ class TestFastPathInvocation: assert mock_catalog.call_count == 0 assert result == ("anthropic/claude-opus-4.7", "openrouter", False) + def test_codex_with_stale_openai_slash_id_uses_catalog_repair(self): + """Codex must repair stale OpenRouter-shaped OpenAI IDs. + + Browser/localStorage state can submit ``openai/gpt-...`` while the + session/provider is ``openai-codex``. If the fast path preserves that + pair, runtime resolution routes through OpenRouter instead of Codex. + The Codex + ``openai/...`` shape must therefore use the slow-path repair + and normalize back to the active Codex default. + """ + from api.routes import _resolve_compatible_session_model_state + + with patch("api.routes.get_available_models") as mock_catalog: + mock_catalog.return_value = { + "active_provider": "openai-codex", + "default_model": "gpt-5.5", + "groups": [ + {"provider_id": "openai-codex", "models": [{"id": "gpt-5.5"}]} + ], + } + result = _resolve_compatible_session_model_state( + "openai/gpt-5.4-mini", + "openai-codex", + ) + + assert mock_catalog.call_count == 1 + assert result == ("gpt-5.5", "openai-codex", True) + def test_fast_path_normalizes_provider_default_alias(self): """`'default'` is treated as None by _clean_session_model_provider. From 12a92dd50a3c9fce51331303f6b89c5c99dd115a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:13:59 +0000 Subject: [PATCH 05/10] =?UTF-8?q?Stage=20397:=20PR=20#2671=20=E2=80=94=20f?= =?UTF-8?q?ix:=20SSE=20reconnect=20jitter=20+=20cron=20run=20row=20inline?= =?UTF-8?q?=20expansion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2629 Closes #2661 Co-authored-by: AJV20 --- CHANGELOG.md | 5 +++++ static/panels.js | 15 +++++++++----- static/sessions.js | 15 +++++++++++++- tests/test_issue2661_2629_frontend.py | 29 +++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 tests/test_issue2661_2629_frontend.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 356fdf0e..d074f236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/static/panels.js b/static/panels.js index 40a45391..cab5d46d 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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); diff --git a/static/sessions.js b/static/sessions.js index 3822a60a..c3bc50ae 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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(); diff --git a/tests/test_issue2661_2629_frontend.py b/tests/test_issue2661_2629_frontend.py new file mode 100644 index 00000000..8acd439d --- /dev/null +++ b/tests/test_issue2661_2629_frontend.py @@ -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 From 3dd2ace4e1629bbe8ad0c7a028d5553e3102e6fe Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:14:33 +0000 Subject: [PATCH 06/10] =?UTF-8?q?Stage=20397:=20PR=20#2689=20=E2=80=94=20f?= =?UTF-8?q?ix(chat):=20preserve=20inflight=20send=20state=20during=20start?= =?UTF-8?q?=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ai-ag2026 --- static/messages.js | 9 ++++- static/sessions.js | 3 ++ tests/test_inflight_send_start_race.py | 51 ++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 tests/test_inflight_send_start_race.py diff --git a/static/messages.js b/static/messages.js index b5052fb8..e5477f30 100644 --- a/static/messages.js +++ b/static/messages.js @@ -415,7 +415,8 @@ async function send(){ if(typeof upsertActiveSessionForLocalTurn==='function'){ upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); } - INFLIGHT[activeSid]={messages:[...S.messages],uploaded:uploadedNames,toolCalls:[]}; + const optimisticMessages=[...S.messages]; + INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]}; if(typeof saveInflightState==='function'){ saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]}); } @@ -486,9 +487,13 @@ async function send(){ // against real active-stream metadata before the background refresh lands. upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); } + if(!INFLIGHT[activeSid]){ + INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]}; + } + const currentInflight=INFLIGHT[activeSid]; markInflight(activeSid, streamId); if(typeof saveInflightState==='function'){ - saveInflightState(activeSid,{streamId,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:INFLIGHT[activeSid].toolCalls||[]}); + saveInflightState(activeSid,{streamId,messages:currentInflight.messages||optimisticMessages,uploaded:uploadedNames,toolCalls:currentInflight.toolCalls||[]}); } // Refresh session list so background streaming indicators appear immediately for the // session that was just started and any others that may already be running. diff --git a/static/sessions.js b/static/sessions.js index c3bc50ae..dc27366d 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -310,6 +310,9 @@ function _purgeStaleInflightEntries() { } } for (const sid of Object.keys(INFLIGHT)) { + if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) { + continue; + } if (!sessionsById.has(sid)) { // Session is absent from _allSessions — it was deleted / archived / // filtered and can never stream again, so drop the entry. diff --git a/tests/test_inflight_send_start_race.py b/tests/test_inflight_send_start_race.py new file mode 100644 index 00000000..933e62a4 --- /dev/null +++ b/tests/test_inflight_send_start_race.py @@ -0,0 +1,51 @@ +"""Regression coverage for send/start optimistic INFLIGHT races.""" +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8") +SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_body(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + brace = src.index("{", start) + depth = 1 + i = brace + 1 + while depth and i < len(src): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + return src[brace + 1 : i - 1] + + +def test_send_preserves_optimistic_messages_across_chat_start_await(): + """send() must not dereference INFLIGHT[activeSid] after await without a fallback.""" + body = _function_body(MESSAGES_JS, "send") + setup_idx = body.index("const optimisticMessages=[...S.messages];") + inflight_idx = body.index("INFLIGHT[activeSid]={messages:optimisticMessages") + await_idx = body.index("const startData=await api('/api/chat/start'") + save_idx = body.index("saveInflightState(activeSid,{streamId", await_idx) + + assert setup_idx < inflight_idx < await_idx < save_idx + post_await = body[await_idx:save_idx] + assert "if(!INFLIGHT[activeSid])" in post_await, ( + "send() should recreate the INFLIGHT entry if a session-list refresh pruned it" + ) + assert "messages:INFLIGHT[activeSid].messages" not in body[save_idx : save_idx + 220], ( + "saveInflightState() should use a guarded local/current inflight object, not a blind nested read" + ) + + +def test_stale_inflight_purge_preserves_current_send_before_stream_id_exists(): + """Sidebar cleanup must not delete the active send before /api/chat/start responds.""" + body = _function_body(SESSIONS_JS, "_purgeStaleInflightEntries") + + assert "_sendInProgress" in body and "_sendInProgressSid" in body, ( + "_purgeStaleInflightEntries() should skip the current send while start is in progress" + ) + skip_idx = body.index("_sendInProgress") + delete_idx = body.index("delete INFLIGHT[sid];") + assert skip_idx < delete_idx, "the current-send skip must run before any purge deletion" From d5dcd609819749c9cb0bbd8c7d710b36c570b3f7 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:14:34 +0000 Subject: [PATCH 07/10] =?UTF-8?q?Stage=20397:=20PR=20#2674=20=E2=80=94=20f?= =?UTF-8?q?ix:=20new-chat=20default=20model=20provider=20sync=20when=20mod?= =?UTF-8?q?el=20id=20matches=20but=20provider=20differs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: AJV20 --- static/sessions.js | 21 +++++++-- tests/test_new_chat_default_model_frontend.py | 46 +++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 tests/test_new_chat_default_model_frontend.py diff --git a/static/sessions.js b/static/sessions.js index dc27366d..768dc35e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -477,11 +477,22 @@ async function newSession(flash, options={}){ try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} _setActiveSessionUrl(S.session.session_id); _setSessionViewedCount(S.session.session_id, S.session.message_count || 0); - // Sync chat-header dropdown to the session's model so the UI reflects - // the default model the server actually used (#872). - if(S.session.model && S.session.model!==$('modelSelect').value && typeof _applyModelToDropdown==='function'){ - _applyModelToDropdown(S.session.model,$('modelSelect'),S.session.model_provider||null); - if(typeof syncModelChip==='function') syncModelChip(); + // Sync chat-header dropdown to the session's model/provider so the UI reflects + // the default route the server actually used (#872). Compare provider state too: + // duplicate model ids can exist under several providers, and a stale persisted + // picker selection with the same model id should not mask the new session's + // configured default provider. + const modelSel=$('modelSelect'); + if(S.session.model && modelSel && typeof _applyModelToDropdown==='function'){ + const currentModelState=(typeof _modelStateForSelect==='function') + ? _modelStateForSelect(modelSel,modelSel.value) + : {model:modelSel.value,model_provider:null}; + const sessionProvider=S.session.model_provider||null; + const currentProvider=currentModelState.model_provider||null; + if(S.session.model!==modelSel.value || sessionProvider !== currentProvider){ + _applyModelToDropdown(S.session.model,modelSel,sessionProvider); + if(typeof syncModelChip==='function') syncModelChip(); + } } // Reset per-session visual state: a fresh chat is idle even if another // conversation is still streaming in the background. diff --git a/tests/test_new_chat_default_model_frontend.py b/tests/test_new_chat_default_model_frontend.py new file mode 100644 index 00000000..e12399bc --- /dev/null +++ b/tests/test_new_chat_default_model_frontend.py @@ -0,0 +1,46 @@ +from pathlib import Path + +SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8") +MESSAGES_JS = Path("static/messages.js").read_text(encoding="utf-8") +CHANGELOG = Path("CHANGELOG.md").read_text(encoding="utf-8") + + +def _extract_function(source: str, signature: str) -> str: + start = source.index(signature) + # Look for the function body's opening brace, not an object literal inside + # a default argument such as `options={}`. + brace = source.index("{\n", start) + depth = 0 + for idx in range(brace, len(source)): + ch = source[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return source[start : idx + 1] + raise AssertionError(f"Function body not closed for {signature}") + + +def _new_session_function() -> str: + return _extract_function(SESSIONS_JS, "async function newSession") + + +def test_new_chat_syncs_model_picker_when_default_provider_changes_but_model_id_matches(): + fn = _new_session_function() + assert "currentModelState" in fn + assert "currentProvider" in fn + assert "sessionProvider" in fn + assert "sessionProvider !== currentProvider" in fn + assert "_applyModelToDropdown(S.session.model,modelSel,sessionProvider)" in fn + + +def test_new_chat_does_not_send_stale_dropdown_model_when_session_has_default_model(): + assert "model:S.session.model||$('modelSelect').value" in MESSAGES_JS + assert "model_provider:S.session.model_provider||null" in MESSAGES_JS + + +def test_changelog_mentions_new_chat_default_model_provider_sync(): + unreleased = CHANGELOG.split("## [v0.51.103]", 1)[0] + assert "New conversations now resync" in unreleased + assert "default model provider" in unreleased From e2338f696fdc3a0df069019c64fdf4f315645538 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:14:36 +0000 Subject: [PATCH 08/10] =?UTF-8?q?Stage=20397:=20PR=20#2688=20=E2=80=94=20f?= =?UTF-8?q?ix:=20resync=20virtualized=20sidebar=20after=20scroll=20clamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ai-ag2026 --- static/sessions.js | 17 ++++++ ...st_issue500_session_list_virtualization.py | 52 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/static/sessions.js b/static/sessions.js index 768dc35e..5e57b354 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2932,6 +2932,22 @@ function _markSessionListPointerUp(){ if(_pendingSessionListPayload) _schedulePendingSessionListApply(); } +let _sessionVirtualResyncRaf = 0; +function _resyncSessionVirtualWindowAfterRender(list, expectedScrollTop, virtualWindow){ + if(!list||!virtualWindow||!virtualWindow.virtualized) return; + expectedScrollTop=Number(expectedScrollTop)||0; + if(expectedScrollTop<=0) return; + if(_sessionVirtualResyncRaf) cancelAnimationFrame(_sessionVirtualResyncRaf); + _sessionVirtualResyncRaf=requestAnimationFrame(()=>{ + _sessionVirtualResyncRaf=0; + if(_renamingSid) return; + const actualScrollTop=Number(list.scrollTop)||0; + const tolerance=Math.max(2, Number(virtualWindow.itemHeight||SESSION_VIRTUAL_ROW_HEIGHT)/2); + if(Math.abs(actualScrollTop-expectedScrollTop)<=tolerance) return; + renderSessionListFromCache(); + }); +} + function renderSessionListFromCache(){ // Don't re-render while user is actively renaming a session (would destroy the input) if(_renamingSid) return; @@ -3214,6 +3230,7 @@ function renderSessionListFromCache(){ // scrollTop drops to 0 — producing a "scroll keeps jumping back" feel // when the list scrolls naturally. Fixed for #1669 follow-up. list.scrollTop=listScrollTopBeforeRender; + _resyncSessionVirtualWindowAfterRender(list, listScrollTopBeforeRender, virtualWindow); } // Select mode toggle button (only when NOT in select mode) if(!_sessionSelectMode){ diff --git a/tests/test_issue500_session_list_virtualization.py b/tests/test_issue500_session_list_virtualization.py index dc6270f8..6d663e04 100644 --- a/tests/test_issue500_session_list_virtualization.py +++ b/tests/test_issue500_session_list_virtualization.py @@ -138,3 +138,55 @@ def test_session_list_only_moves_to_active_when_active_row_is_not_visible(): assert before_idx < visible_idx < move_idx < final_idx < anchor_idx assert "activeIndex:-1" in render_body[before_idx:visible_idx] assert "activeIndex:shouldAnchorActive?activeIndex:-1" not in render_body + + +def test_session_list_resyncs_when_browser_clamps_virtual_scroll_restore(): + """If a hidden/reflowed sidebar rejects restored scrollTop, re-render the visible window.""" + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + render_start = js.index("function renderSessionListFromCache()") + render_end = js.index("async function _handleActiveSessionStorageEvent", render_start) + render_body = js[render_start:render_end] + + assert "_resyncSessionVirtualWindowAfterRender(list, listScrollTopBeforeRender, virtualWindow);" in render_body + + source = _extract_func_script(js) + """ +let renderCount = 0; +let rafCount = 0; +let _renamingSid = null; +const SESSION_VIRTUAL_ROW_HEIGHT = 52; +function requestAnimationFrame(cb){ rafCount += 1; cb(); return rafCount; } +function cancelAnimationFrame(_id){} +function renderSessionListFromCache(){ renderCount += 1; } +const makeHelper = new Function( + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'renderSessionListFromCache', + `let _sessionVirtualResyncRaf = 0; + let _renamingSid = null; + const SESSION_VIRTUAL_ROW_HEIGHT = 52; + ${extractFunc('_resyncSessionVirtualWindowAfterRender')} + return _resyncSessionVirtualWindowAfterRender;` +); +const _resyncSessionVirtualWindowAfterRender = makeHelper( + requestAnimationFrame, + cancelAnimationFrame, + renderSessionListFromCache +); + +_resyncSessionVirtualWindowAfterRender( + {scrollTop: 0}, + 52 * 10, + {virtualized: true, itemHeight: 52} +); +const afterClamp = renderCount; +_resyncSessionVirtualWindowAfterRender( + {scrollTop: 52 * 10}, + 52 * 10, + {virtualized: true, itemHeight: 52} +); +console.log(JSON.stringify({afterClamp, final: renderCount, rafCount})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["afterClamp"] == 1 + assert metrics["final"] == 1 + assert metrics["rafCount"] == 2 From 8e1ac89baa7665903b4e5d7d97cb1cf30fe685e0 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:14:37 +0000 Subject: [PATCH 09/10] =?UTF-8?q?Stage=20397:=20PR=20#2692=20=E2=80=94=20f?= =?UTF-8?q?ix(ui):=20invalidate=20transcript=20cache=20on=20same-count=20c?= =?UTF-8?q?ontent=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ai-ag2026 --- static/ui.js | 61 ++++++++++++++++--- .../test_issue2613_render_cache_signature.py | 26 ++++++++ tests/test_issue734_message_windowing.py | 3 +- 3 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 tests/test_issue2613_render_cache_signature.py diff --git a/static/ui.js b/static/ui.js index caadaf88..88a53c33 100644 --- a/static/ui.js +++ b/static/ui.js @@ -5537,14 +5537,9 @@ function renderCompressionUi(){ el.style.display='none'; } // Session render cache: avoids full markdown+DOM rebuild when switching back -// to a session that was already rendered with the same message count. +// to a session whose rendered transcript inputs are unchanged. // Keyed by session_id. Only used on cross-session navigation, never for // in-session updates (new messages, edits, stream events). -// -// Known limitation: cache key is session_id + message count. Edits and retries -// that mutate message content without changing the count will serve stale HTML -// on back-navigation until the user triggers an in-session update. Acceptable -// for the common read-only back-navigation case; not suitable as a general cache. const _sessionHtmlCache=new Map(); let _sessionHtmlCacheSid=null; // session_id currently rendered in the DOM function clearMessageRenderCache(){ @@ -5552,6 +5547,55 @@ function clearMessageRenderCache(){ _sessionHtmlCacheSid=null; } +function _messageRenderCacheSignature(){ + let hash=2166136261; + function add(value){ + const s=String(value==null?'':value); + for(let i=0;i>>0; + } + hash^=31; + hash=Math.imul(hash,16777619)>>>0; + } + const messages=Array.isArray(S.messages)?S.messages:[]; + add(messages.length); + for(const m of messages){ + if(!m||typeof m!=='object'){ add('missing'); continue; } + add(m.role);add(m.timestamp);add(m._ts);add(m._error);add(m._statusCard); + add(msgContent(m)); + if(Array.isArray(m.content)){ + add('content-array'); + m.content.forEach(part=>{ + if(!part||typeof part!=='object'){ add(part); return; } + add(part.type);add(part.id);add(part.name);add(part.text);add(part.content); + }); + } + if(Array.isArray(m.tool_calls)){ + add('message-tool-calls');add(m.tool_calls.length); + m.tool_calls.forEach(tc=>{add(tc&&tc.id);add(tc&&tc.name);add(tc&&tc.type);add(JSON.stringify(tc&&tc.function||{}));}); + } + if(Array.isArray(m._partial_tool_calls)){ + add('partial-tool-calls');add(m._partial_tool_calls.length); + m._partial_tool_calls.forEach(tc=>{add(tc&&tc.id);add(tc&&tc.name);add(tc&&tc.snippet);}); + } + if(_messageHasReasoningPayload(m)) add(m.reasoning||m.thinking||m._reasoning||'reasoning'); + if(Array.isArray(m.attachments)) m.attachments.forEach(a=>add(a&&typeof a==='object'?JSON.stringify(a):a)); + } + const toolCalls=Array.isArray(S.toolCalls)?S.toolCalls:[]; + add('settled-tool-calls');add(toolCalls.length); + toolCalls.forEach(tc=>{ + if(!tc||typeof tc!=='object'){ add(tc); return; } + add(tc.tid);add(tc.id);add(tc.name);add(tc.done);add(tc.is_diff);add(tc.assistant_msg_idx);add(tc.snippet);add(JSON.stringify(tc.args||{})); + }); + if(S.session){ + add(S.session.message_count);add(S.session.updated_at);add(S.session.compression_anchor_visible_idx); + add(JSON.stringify(S.session.compression_anchor_message_key||null)); + add(S.session.compression_anchor_summary||''); + } + return `${messages.length}:${toolCalls.length}:${hash.toString(16)}`; +} + function _clipCliToolSnippet(text, maxLen=20000){ const s=String(text||''); if(s.length<=maxLen) return s; @@ -5698,6 +5742,7 @@ function renderMessages(options){ const msgCount=S.messages.length; if(sid!==_messageRenderWindowSid) _resetMessageRenderWindow(sid); const renderWindowSize=_currentMessageRenderWindowSize(); + const renderSignature=_messageRenderCacheSignature(); const hasTransientTranscriptUi=!!( (window._compressionUi&&(!window._compressionUi.sessionId||window._compressionUi.sessionId===sid)) || (window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid)) @@ -5713,7 +5758,7 @@ function renderMessages(options){ // before those cards can be inserted. if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){ const cached=_sessionHtmlCache.get(sid); - if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize){ + if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize&&cached.signature===renderSignature){ inner.innerHTML=cached.html; _sessionHtmlCacheSid=sid; _wireMessageWindowLoadEarlierButton(); @@ -6324,7 +6369,7 @@ function renderMessages(options){ const _html=inner.innerHTML; // Only cache sessions with <300KB rendered HTML; evict oldest beyond 8 sessions. if(_html.length<300_000){ - _sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize}); + _sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature}); if(_sessionHtmlCache.size>8){_sessionHtmlCache.delete(_sessionHtmlCache.keys().next().value);} } } diff --git a/tests/test_issue2613_render_cache_signature.py b/tests/test_issue2613_render_cache_signature.py new file mode 100644 index 00000000..bb812291 --- /dev/null +++ b/tests/test_issue2613_render_cache_signature.py @@ -0,0 +1,26 @@ +from pathlib import Path + + +UI_JS = Path("static/ui.js").read_text(encoding="utf-8") + + +def test_session_html_cache_uses_render_signature_not_only_count(): + assert "function _messageRenderCacheSignature()" in UI_JS + assert "const renderSignature=_messageRenderCacheSignature();" in UI_JS + assert "cached.signature===renderSignature" in UI_JS + assert "signature:renderSignature" in UI_JS + + +def test_render_signature_tracks_message_content_and_settled_tool_cards(): + signature_fn = UI_JS[UI_JS.index("function _messageRenderCacheSignature()"):UI_JS.index("function _clipCliToolSnippet")] + assert "msgContent(m)" in signature_fn + assert "m.tool_calls" in signature_fn + assert "m._partial_tool_calls" in signature_fn + assert "S.toolCalls" in signature_fn + assert "tc.snippet" in signature_fn + assert "compression_anchor_summary" in signature_fn + + +def test_documentation_no_longer_allows_same_count_stale_html(): + assert "Known limitation: cache key is session_id + message count" not in UI_JS + assert "mutate message content without changing the count will serve stale HTML" not in UI_JS diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index 992c5046..0352f0b6 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -33,7 +33,8 @@ def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_ def test_window_state_participates_in_cache_and_cached_button_is_rewired(): assert "cached.renderWindowSize===renderWindowSize" in UI_JS - assert "_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize})" in UI_JS + assert "cached.signature===renderSignature" in UI_JS + assert "_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature})" in UI_JS assert "function _wireMessageWindowLoadEarlierButton()" in UI_JS assert "_wireMessageWindowLoadEarlierButton();" in UI_JS assert UI_JS.count("_wireMessageWindowLoadEarlierButton();") >= 2 From e6d51c2c489b80abac40bdc34091909ffde4ea9b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:15:56 +0000 Subject: [PATCH 10/10] Stamp CHANGELOG for v0.51.104 (Release CB / stage-397 / 9-PR batch) --- CHANGELOG.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d074f236..50e9e5dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,20 @@ ## [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.104] — 2026-05-21 — Release CB (stage-397 — 9-PR batch — i18n zh-CN/zh-TW cron status + geist-contrast skin polish + tablet hardware Enter + stale Codex slash model state + SSE reconnect jitter + cron run inline expansion + inflight send race + new-chat model provider sync + virtualized sidebar scroll-clamp resync + transcript cache invalidation on same-count content) +### Fixed + +- **PR #2690** by @laiaman — Correct the zh-CN and zh-Hant translations for the `cron_status_active` label so it reads "enabled / scheduled" (`已启用` / `已啟用`) instead of "running" (`运行中` / `活躍中`). The English source is "active" (enabled, scheduled), and the prior Chinese strings conflated it with the separate `cron_status_running` "currently executing" state, making both labels look identical when a job was both scheduled and not currently firing. +- **PR #2701** by @jasonjcwu — Geist-contrast skin composer polish: force `--user-bubble-text` to `#111` in light mode so typed text is black on the light input background; hide the textarea scrollbar to match the rest of the skin; recolor the send button so it reads correctly against the contrast palette. +- **PR #2706** by @dobby-d-elf — Tablet (iPad-class) devices with an attached hardware keyboard now send on Enter and newline on Shift+Enter, matching desktop behavior. The prior touch-primary check forced Enter→newline on every touch device, but tablets with hardware keyboards have a physical Shift key and should follow the desktop contract. Detection uses `matchMedia('(pointer:coarse)')` + a `window.visualViewport` height-delta probe (>120px shrink = software keyboard open) so an iPad with hardware keyboard (viewport not shrunk) treats Enter as send, while a phone tapping into the composer (soft keyboard shrinks the viewport) keeps Enter as newline. Falls back to the legacy touch behavior when `visualViewport` is unavailable. +- **PR #2684** by @ai-ag2026 — Repair stale `openai/...` slash-qualified model IDs when the active/session provider is `openai-codex`. A stale browser/localStorage selection of `openai/gpt-5` against an `openai-codex` provider previously routed the chat to OpenAI directly instead of through Codex. The cross-provider model-switch resolver now detects the mismatch and re-resolves the model to the matching `codex/...` ID before the request goes out. Explicit OpenRouter slash-qualified selections continue to fast-path through unchanged. +- **PR #2671** by @AJV20 (closes #2629 + #2661) — Session-list SSE reconnects now use bounded jitter/backoff (each retry delay is `base*0.75 + random*(base*0.35)` where `base = min(30000, 5000 * 2^attempt)`, capped at 30s) instead of a fixed 5-second retry, so tabs that all dropped at the same time (server restart, network drop) don't all retry in lockstep. Expanded cron run rows now render the full output inline immediately on click; the truncated preview remains only for collapsed rows, and the full-output fallback no longer drops content when Markdown rendering is unavailable. +- **PR #2689** by @ai-ag2026 — Preserve the optimistic in-flight message array across the `/api/chat/start` await window so a fast back-to-back send doesn't clear the user's message before the stream ID arrives. The fix snapshots the inflight entry before the await, recreates it if a sidebar/session refresh pruned it during that window, and skips stale-inflight cleanup for the submitting session until a stream ID is bound. Regression test covers the race. +- **PR #2674** by @AJV20 — Resync the new-chat model picker when the server-created session has the same model ID as the current dropdown but a different provider. New conversations now resync to the configured default model provider instead of inheriting a stale persisted picker selection (e.g. `openai/gpt-5` from a previous session). Without this, the dropdown text matched the new session's model, but the provider attribute still pointed at the stale choice. +- **PR #2688** by @ai-ag2026 — Resync the virtualized session sidebar after restoring a saved scroll position if the browser clamps or rejects that scroll position. Without this, date-group headers could render without their session rows beneath them until the user manually scrolled or a later refresh recomputed the virtual window. Regression test pins the recompute path. +- **PR #2692** by @ai-ag2026 (refs #2613) — Invalidate the transcript render cache on same-count content changes, not just on count changes. The prior cache key was `(message_count, render_window_size)`, which silently reused a cached transcript whenever a same-count edit produced visibly different content (e.g. a tool retry that replaces a single assistant message with corrected text). The new cache signature folds a content hash into the key so any visible change forces a fresh render. Regression test asserts cache-bust on same-count content swap. ## [v0.51.103] — 2026-05-21 — Release CA (stage-396 — 1-PR follow-on — Settings → Plugins distinguishes exclusive/provider activation)