mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Merge pull request #2707 from nesquena/release/stage-397
Release CB — stage-397 — v0.51.104 — 9-PR low-risk batch (i18n + geist polish + tablet kbd + Codex slash repair + SSE jitter + inline cron + inflight race + model picker resync + sidebar clamp + transcript cache)
This commit is contained in:
@@ -4,6 +4,20 @@
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [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)
|
||||
|
||||
### Fixed
|
||||
|
||||
+15
-3
@@ -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
|
||||
|
||||
+9
-3
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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',
|
||||
|
||||
+7
-2
@@ -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.
|
||||
|
||||
+10
-5
@@ -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);
|
||||
|
||||
+50
-6
@@ -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.
|
||||
@@ -474,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.
|
||||
@@ -2112,6 +2126,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 +2257,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 +2269,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();
|
||||
@@ -2905,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;
|
||||
@@ -3187,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){
|
||||
|
||||
+9
-3
@@ -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{
|
||||
|
||||
+53
-8
@@ -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<s.length;i++){
|
||||
hash^=s.charCodeAt(i);
|
||||
hash=Math.imul(hash,16777619)>>>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);}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user