// `todos` is the single source of truth for the Todos panel. Any update
// goes through the `todo_state` SSE event (live) or session.todo_state
// (cold-load). `todoStateMeta` doubles as a sentinel: while it is null
// no explicit signal has been seen, so loadTodos() falls back to the
// legacy reverse-scan over S.messages — that keeps new clients working
// against old servers (Phase 1 may not yet be deployed everywhere).
// See api/todo_state.py for the wire contract.
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default',activeProfileIsDefault:true,showHiddenWorkspaceFiles:false,todos:[],todoStateMeta:null,_pendingSessionToolsets:null};
function assistantDisplayName(){
if(S.activeProfile&&S.activeProfile!=='default') return S.activeProfile.charAt(0).toUpperCase()+S.activeProfile.slice(1);
return window._botName||'Hermes';
}
const INFLIGHT={}; // keyed by session_id while request in-flight
const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
const MAX_UPLOAD_BYTES=(window.__HERMES_CONFIG__&&window.__HERMES_CONFIG__.maxUploadBytes)||20*1024*1024;
const MAX_UPLOAD_MB=Math.round(MAX_UPLOAD_BYTES/1024/1024);
// Tracks which session's queue to drain in setBusy(false).
// Set to activeSid just before setBusy(false) in done/error handlers so the
// queue drains the session that *finished*, not the one currently viewed.
// Single-shot: setBusy() reads and clears this on every call. Concurrent
// back-to-back stream completions would overwrite it, but HTTPServer is
// single-threaded so only one done event fires at a time in practice.
let _queueDrainSid=null;
const $=id=>document.getElementById(id);
const OFFLINE_RECHECK_MS=2500;
let _offlineVisible=false;
let _offlineReason='browser';
let _offlineProbeTimer=null;
let _offlineChecking=false;
let _offlineProbePromise=null;
let _offlineHealthProbePromise=null;
let _offlineRawFetch=null;
let _offlineFetchPatched=false;
function _browserReportsOnline(){return !('onLine' in navigator)||navigator.onLine!==false;}
function _offlineHealthUrl(){const url=new URL('health',document.baseURI||location.href);url.searchParams.set('offline_probe',String(Date.now()));return url.href;}
function _setOfflineChecking(checking){
_offlineChecking=!!checking;
const btn=$('offlineCheckNow');
if(btn){btn.disabled=_offlineChecking;btn.textContent=_offlineChecking?t('offline_checking'):t('offline_check_now');}
}
function _renderOfflineBanner(){
const banner=$('offlineBanner');
if(!banner)return;
const detail=$('offlineDetails');
if(detail)detail.textContent=t(_offlineReason==='browser'?'offline_browser_detail':'offline_network_detail');
const title=$('offlineTitle');
if(title)title.textContent=t('offline_title');
const auto=$('offlineAutorefresh');
if(auto)auto.textContent=t('offline_autorefresh');
_setOfflineChecking(_offlineChecking);
banner.hidden=false;
banner.classList.add('visible');
}
function _startOfflineProbeTimer(){
if(_offlineProbeTimer)return;
_offlineProbeTimer=setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS);
}
function _stopOfflineProbeTimer(){
if(_offlineProbeTimer){clearInterval(_offlineProbeTimer);_offlineProbeTimer=null;}
}
function showOfflineBanner(reason){
_offlineVisible=true;
_offlineReason=reason||(_browserReportsOnline()?'network':'browser');
_renderOfflineBanner();
_startOfflineProbeTimer();
}
function isOfflineBannerVisible(){return _offlineVisible;}
function _hideOfflineBanner(){
_offlineVisible=false;
_stopOfflineProbeTimer();
_setOfflineChecking(false);
const banner=$('offlineBanner');
if(banner){banner.classList.remove('visible');banner.hidden=true;}
}
async function _probeOfflineRecovery(){
if(_offlineHealthProbePromise)return _offlineHealthProbePromise;
_offlineHealthProbePromise=(async()=>{
const fetcher=_offlineRawFetch||window.fetch.bind(window);
// Bound the probe so a black-hole network (connected, server hung, packets
// dropped) can't delay the banner past a few seconds — the probe now gates
// the initial banner display on the offline-event/startup paths.
let ctrl=null,timer=null;
try{ctrl=(typeof AbortController!=='undefined')?new AbortController():null;}catch(_){ctrl=null;}
if(ctrl)timer=setTimeout(()=>{try{ctrl.abort();}catch(_){}},3000);
try{
const opts={cache:'no-store',credentials:'include'};
if(ctrl)opts.signal=ctrl.signal;
const res=await fetcher(_offlineHealthUrl(),opts);
return !!(res&&res.ok);
}catch(_){return false;}
finally{if(timer)clearTimeout(timer);}
})();
try{return await _offlineHealthProbePromise;}
finally{_offlineHealthProbePromise=null;}
}
async function _showOfflineBannerIfProbeFails(reason){
const visibleAtStart=_offlineVisible;
if(visibleAtStart)_setOfflineChecking(true);
const ok=await _probeOfflineRecovery();
if(visibleAtStart)_setOfflineChecking(false);
if(ok){
if(_offlineVisible){_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();}
return true;
}
showOfflineBanner(reason||(_browserReportsOnline()?'network':'browser'));
return false;
}
async function checkOfflineRecoveryNow(){
if(_offlineProbePromise)return _offlineProbePromise;
_offlineProbePromise=(async()=>{
if(!_offlineVisible)return false;
_setOfflineChecking(true);
const ok=await _probeOfflineRecovery();
_setOfflineChecking(false);
if(ok){if(!_offlineVisible)return true;_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();return true;}
showOfflineBanner(_browserReportsOnline()?'network':'browser');
return false;
})();
try{return await _offlineProbePromise;}
finally{_offlineProbePromise=null;}
}
// Recover from a transient "Connection lost" without a full page reload.
//
// The offline banner fires whenever a fetch/SSE errors — which Android does
// aggressively every time the PWA is backgrounded, even for a second. The old
// behaviour here was `window.location.reload()`: a hard cold boot that re-runs
// the whole app and re-pulls /api/sessions + /api/session, producing the
// multi-second "reload to see the conversation I was just in" flash on every
// resume. The reload was also intermittent (only when a request actually
// errored that time), matching the reported "sometimes it reloads, sometimes
// it doesn't".
//
// The server keeps the agent running and buffers stream events while no
// subscriber is attached (#2307), so a hard reload is never required to
// recover — we just need to reattach. This does the soft path: hide the
// banner, restart the gateway SSE (bfcache/background kills the connection),
// and re-fetch the active session so any messages that landed while we were
// away appear. A full reload is the fallback only if the soft path throws.
async function _recoverFromOfflineSoftly(){
try{
_hideOfflineBanner();
if(typeof startGatewaySSE==='function') startGatewaySSE();
if(S.session && typeof refreshSession==='function'){
await refreshSession();
}
// After refreshSession() sets S.activeStreamId, reattach if a stream is live.
// The server buffers events while no subscriber is attached (#2307/#3863).
const sid=S.session&&S.session.session_id;
const streamId=S.session&&S.session.active_stream_id;
if(sid&&streamId&&typeof attachLiveStream==='function'){
let status=null;
try{
status=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
}catch(_){/* stream status check failed — leave session refreshed but don't reattach */}
// Outside the probe's catch so an attachLiveStream throw reaches the
// outer fallback (hard reload) instead of being silently swallowed.
if(status&&status.active) attachLiveStream(sid,streamId,S.session.pending_attachments||[],{reconnecting:true});
}
return true;
}catch(_){
// Soft reattach failed (server mid-restart, session gone, etc.) — fall
// back to the original hard reload so the user is never stuck offline.
window.location.reload();
return false;
}
}
function _isAbortError(e){return !!(e&&(e.name==='AbortError'||e.code===20));}
function _patchOfflineFetch(){
if(_offlineFetchPatched||typeof window.fetch!=='function')return;
_offlineFetchPatched=true;
_offlineRawFetch=window.fetch.bind(window);
window.fetch=async function(...args){
try{return await _offlineRawFetch(...args);}
catch(e){
if(!_isAbortError(e)&&(e instanceof TypeError||!_browserReportsOnline())){
void _showOfflineBannerIfProbeFails(_browserReportsOnline()?'network':'browser');
}
throw e;
}
};
}
function initOfflineMonitor(){
_patchOfflineFetch();
window.addEventListener('offline',()=>{void _showOfflineBannerIfProbeFails('browser');});
window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();});
if(!_browserReportsOnline())void _showOfflineBannerIfProbeFails('browser');
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',initOfflineMonitor,{once:true});
else initOfflineMonitor();
// Redirect to login when the server responds with 401 (auth session expired).
// Handles iOS PWA standalone mode and keeps subpath mounts like /hermes/ from
// escaping to the personal site root /login.
function _redirectIfUnauth(res){if(res&&res.status===401){window.location.href='login?next='+encodeURIComponent(window.location.pathname+window.location.search);return true;}return false;}
function _getSessionQueue(sid, create=false){
if(!sid) return [];
if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[];
return SESSION_QUEUES[sid]||[];
}
function _queueStorageKey(sid){
return 'hermes-queue-'+sid;
}
function _clearPersistedSessionQueue(sid){
if(!sid) return;
const key=_queueStorageKey(sid);
try{sessionStorage.removeItem(key);}catch(_){}
try{localStorage.removeItem(key);}catch(_){}
}
function _persistSessionQueueStorage(sid, queue){
if(!sid) return;
const q=Array.isArray(queue)?queue:[];
if(!q.length){_clearPersistedSessionQueue(sid);return;}
const key=_queueStorageKey(sid);
let payload='[]';
try{payload=JSON.stringify(q);}catch(_){return;}
try{sessionStorage.setItem(key,payload);}catch(_){}
try{localStorage.setItem(key,payload);}catch(_){}
}
function _readPersistedSessionQueue(sid){
if(!sid) return [];
const key=_queueStorageKey(sid);
const read=(store)=>{
try{
const raw=store&&store.getItem?store.getItem(key):null;
if(!raw) return null;
const parsed=JSON.parse(raw);
return Array.isArray(parsed)?parsed:null;
}catch(_){return null;}
};
const sessionValue=read(sessionStorage);
if(sessionValue&&sessionValue.length) return sessionValue;
const localValue=read(localStorage);
if(localValue&&localValue.length){
try{sessionStorage.setItem(key,JSON.stringify(localValue));}catch(_){}
return localValue;
}
return [];
}
function queueSessionMessage(sid, payload){
if(!sid||!payload) return 0;
const q=_getSessionQueue(sid,true);
// Stamp created_at so the restore path can detect stale entries (agent already responded)
const entry={...payload, _queued_at: Date.now()};
q.push(entry);
_persistSessionQueueStorage(sid,q);
return q.length;
}
function shiftQueuedSessionMessage(sid){
const q=_getSessionQueue(sid,false);
if(!q.length) return null;
const next=q.shift();
if(!q.length){
delete SESSION_QUEUES[sid];
_clearPersistedSessionQueue(sid);
} else {
_persistSessionQueueStorage(sid,q);
}
return next;
}
function getQueuedSessionCount(sid){
return _getSessionQueue(sid,false).length;
}
function _compressionSessionLock(){
return window._compressionLockSid||null;
}
function _setCompressionSessionLock(sid){
window._compressionLockSid=sid||null;
}
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
function _matchBacktickFenceLine(line){
const m=String(line||'').match(/^[ ]{0,3}(`{3,})([^`]*)$/);
if(!m) return null;
return {fence:m[1],len:m[1].length,info:(m[2]||'').trim()};
}
function _isBacktickFenceClose(line,minLen){
const m=String(line||'').match(/^[ ]{0,3}(`{3,})[ \t]*$/);
return !!(m&&m[1].length>=minLen);
}
/**
* Render fenced code blocks inside user messages.
* Extracts ```…``` fences, replaces them with placeholders,
* escapes remaining text as plain HTML, then restores code blocks
* with the same
pipeline used by renderMd().
* All non-fenced text stays escaped (no bold/italic/link interpretation).
*/
function _stripWorkspaceDisplayPrefix(text){
// v1 sentinel format `[Workspace::v1: ]\n` injected since #1918.
// Legacy format `[Workspace: ]\n` may still be present in transcripts
// saved before the v1 migration; fall through to the legacy regex when the
// v1 strip didn't match. Mirrors the Python `include_legacy=True` branch in
// api/streaming.py:_strip_workspace_prefix(). Per Opus advisor on stage-322.
const value = String(text||'');
const stripped = value.replace(/^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*/,'');
if(stripped !== value) return stripped.trim();
return value.replace(/^\s*\[Workspace:[^\]]+\]\s*/,'').trim();
}
function _renderUserFencedBlocks(text){
const stash=[];
const contextStash=[];
const mathStash=[];
const stashMath=(type,src)=>{mathStash.push({type,src});return '\x00UM'+(mathStash.length-1)+'\x00';};
const sentContextHtml=(label,quoteText)=>{
const safeLabel=String(label||'').trim()||'Context';
const safeQuote=String(quoteText||'').replace(/\s+$/,'');
return `${esc(safeLabel)} ${esc(safeQuote)} `;
};
const stashContext=(label,quote)=>{contextStash.push(sentContextHtml(label,quote));return '\x00UC'+(contextStash.length-1)+'\x00';};
const stashSelectedContextBlocks=(value)=>{
const lines=String(value||'').split('\n');
const marker='';
const out=[];
for(let i=0;i/.test(lines[j])){
quoteLines.push(lines[j].replace(/^>[ \t]?/,''));
j++;
}
if(!quoteLines.length){out.push(lines[i]);continue;}
out.push(stashContext(labelMatch[1], quoteLines.join('\n')));
i=j-1;
}
return out.join('\n');
};
const restoreMath=html=>String(html||'').replace(/\x00UM(\d+)\x00/g,(_,i)=>{
const item=mathStash[+i];
if(!item) return '';
if(item.type==='display') return `${esc(item.src)}
`;
return `${esc(item.src)} `;
});
let s=String(text||'');
// Extract fenced code blocks FIRST so math regexes never run inside fenced
// content. If math were stashed first, a user-typed code block containing
// \[..\] / \(..\) / $$..$$ would be rendered as a KaTeX block inside
// instead of as literal source. Mirrors renderMd()'s ordering.
// CommonMark §4.5 line-anchored fence: the closing run must use at least
// as many backticks as the opener, so inner triple-backtick fences remain content.
s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{
const langInfo=(info||'').trim();
const langMatch=langInfo.match(/^(\w[\w+-]*)$/);
let lang=langMatch?(langMatch[1]||'').trim().toLowerCase():'';
code=code||'';
// Remove one trailing newline if present (the fence consumes its own)
if(code.endsWith('\n')) code=code.slice(0,-1);
const h=lang?``:'';
const langAttr=lang?` class="language-${esc(lang)}"`:'';
const preClass=/^(md|markdown|mdx)$/.test(lang)?' class="md-source-block"':'';
if(lang==='diff'||lang==='patch'){
const colored=esc(code).split('\n').map(line=>{
if(line.startsWith('@@')) return `${line} `;
if(line.startsWith('+')) return `${line} `;
if(line.startsWith('-')) return `${line} `;
return `${line} `;
}).join('\n');
stash.push(`${h}${colored} `);
} else {
stash.push(`${h}${esc(code)} `);
}
return lead+'\x00UF'+(stash.length-1)+'\x00';
});
// Now stash math from the OUTSIDE-of-fence text. Display delimiters must
// run before inline so $$..$$ isn't mis-parsed as $..$..$..$.
s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>stashMath('display',m));
s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>stashMath('display',m));
s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>stashMath('inline',m));
s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>stashMath('inline',m));
// Render selected-context payloads produced by Reply with selection as calm
// quote cards in the sent user bubble. Keep ordinary user Markdown escaped;
// only blocks carrying the internal marker get custom treatment.
s=stashSelectedContextBlocks(s);
// Escape remaining plain text and convert newlines to
s=esc(s).replace(/\n/g,' ');
// Restore stashed code/context blocks, then math placeholders as KaTeX targets.
s=s.replace(/\x00UF(\d+)\x00/g,(_,i)=>stash[+i]);
s=s.replace(/\x00UC(\d+)\x00/g,(_,i)=>contextStash[+i]||'');
s=restoreMath(s);
return s;
}
function _statusCardHtml(card){
card=card||{};
const rows=Array.isArray(card.rows)?card.rows:[];
const sessionId=String(card.sessionId||'');
const shortSessionId=sessionId.length>22?`${sessionId.slice(0,10)}…${sessionId.slice(-8)}`:sessionId;
const copyIcon=(typeof li==='function')?li('copy',13):'Copy';
const copyBtn=sessionId
? `${esc(shortSessionId)} ${copyIcon} `
: '';
const rowHtml=rows.map(row=>`
${esc(row.label||'')}
${esc(row.value||'')}
`).join('');
return `
${esc(card.title||t('status_heading'))}
${esc(card.subtitle||'')}
${copyBtn}
${rowHtml}
`;
}
const MESSAGE_RENDER_WINDOW_DEFAULT=50;
const MESSAGE_VIRTUAL_THRESHOLD_ROWS=80;
const MESSAGE_VIRTUAL_BUFFER_PX=900;
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={
user:120,
assistant:160,
tool_call:400,
default:140,
};
function _messageVirtualDefaultHeightForRole(role){
return MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS[
role&&Object.prototype.hasOwnProperty.call(MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS,role)?role:'default'
];
}
const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=2;
let _messageRenderWindowSid=null;
let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
let _messageVirtualHeightCache=[];
let _messageVirtualHeightCacheEntries=[];
let _messageVirtualHeightCacheLen=0;
let _messageVirtualHeightCacheSrc=null;
let _messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('default');
let _messageVirtualScrollRaf=0;
let _messageVirtualWindowKey='';
let _messageVirtualMeasurementCycleKey='';
let _messageVirtualMeasurementRetryCount=0;
let _messageVirtualScrollActive=false;
let _messageVirtualScrollSettleTimer=0;
let _messageVirtualDeferredMeasurement=null;
let _msgNodeRecycleEnabled=false;
const _recycleStash=new Map();
const _recycleResetAttrs=[
'data-transparent-turn-collapsed',
'data-transparent-turn-toggle-bound',
'data-anchor-scene-live-owner',
'data-anchor-stream-id',
// Defensive reset for legacy/restored shells that may still carry the fallback live-turn marker.
'data-live-assistant-turn',
];
let _scrollbarDragActive=false;
function _markMessageVirtualScrollActive(){
_messageVirtualScrollActive=true;
clearTimeout(_messageVirtualScrollSettleTimer);
_messageVirtualScrollSettleTimer=setTimeout(()=>{
_messageVirtualScrollActive=false;
if(_messageVirtualDeferredMeasurement){
const deferred=_messageVirtualDeferredMeasurement;
_messageVirtualDeferredMeasurement=null;
_scheduleMessageVirtualMeasurementRefresh(deferred);
}
},150);
}
// Cached visWithIdx array — invalidated when S.messages.length changes.
let _visWithIdxCache=null;
let _visWithIdxCacheLen=0;
let _visWithIdxCacheSrc=null; // S.messages reference — detects wholesale replacement with same length
function clearVisibleMessageRowCache(){
_visWithIdxCache=null;
_visWithIdxCacheLen=0;
_visWithIdxCacheSrc=null;
}
function _clearMessageVirtualHeightCache(){
_messageVirtualHeightCache=[];
_messageVirtualHeightCacheEntries=[];
_messageVirtualHeightCacheLen=0;
_messageVirtualHeightCacheSrc=null;
_messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('default');
_messageVirtualWindowKey='';
_messageVirtualMeasurementCycleKey='';
_messageVirtualMeasurementRetryCount=0;
_messageVirtualScrollActive=false;
clearTimeout(_messageVirtualScrollSettleTimer);
_messageVirtualScrollSettleTimer=0;
_messageVirtualDeferredMeasurement=null;
}
function _resetMessageRenderWindow(sid){
_messageRenderWindowSid=sid||null;
_messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
_cancelMessageVirtualizedRender();
_clearRenderCache();
clearVisibleMessageRowCache();
_clearMessageVirtualHeightCache();
}
function _cancelMessageVirtualizedRender(){
if(_messageVirtualScrollRaf){
cancelAnimationFrame(_messageVirtualScrollRaf);
_messageVirtualScrollRaf=0;
}
}
function _messageIsRenderable(m){
if(!m||!m.role||m.role==='tool') return false;
if(m._source === 'process_wakeup') return false;
if(_isContextCompactionMessage(m)||_isPreservedCompressionTaskListMessage(m)) return false;
if(_isRecoveryControlMessage(m)) return false;
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0;
const hasReasoningAnchor=hasTc||hasTu||_messageHasReasoningPayload(m);
const hasAssistantVisibleAnchor=hasTc||hasTu||hasPartialTc||_messageHasReasoningPayload(m)||_assistantMessageHasVisibleContent(m);
return !!(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasReasoningAnchor||hasAssistantVisibleAnchor)));
}
function _getVisibleMessagesWithIdx(){
if(!_visWithIdxCache || _visWithIdxCacheLen !== S.messages.length || _visWithIdxCacheSrc !== S.messages){
const rebuilt=[];
let rawIdx=0;
for(const m of (S.messages||[])){
if(_messageIsRenderable(m)) rebuilt.push({m,rawIdx});
rawIdx++;
}
_visWithIdxCache=rebuilt;
_visWithIdxCacheLen=S.messages.length;
_visWithIdxCacheSrc=S.messages;
}
return _visWithIdxCache;
}
function _messageVirtualWindow(opts){
const total=Math.max(0, Number(opts&&opts.total)||0);
const threshold=Math.max(1, Number(opts&&opts.threshold)||MESSAGE_VIRTUAL_THRESHOLD_ROWS);
const defaultHeight=Math.max(1, Number(opts&&opts.defaultHeight)||_messageVirtualDefaultHeightForRole('default'));
const bufferPx=Math.max(0, Number(opts&&opts.bufferPx)||MESSAGE_VIRTUAL_BUFFER_PX);
const viewportHeight=Math.max(defaultHeight, Number(opts&&opts.viewportHeight)||defaultHeight*6);
const keepTailCount=Math.max(0, Number(opts&&opts.keepTailCount)||0);
const tailStart=Math.max(0, total-keepTailCount);
const heights=Array.isArray(opts&&opts.heights)?opts.heights:[];
const roleForIdx=typeof (opts&&opts.roleForIdx)==='function'?opts.roleForIdx:null;
const rowHeightFor=(idx)=>{
const cached=Number(heights[idx]);
if(Number.isFinite(cached)&&cached>0) return cached;
return roleForIdx?Math.max(1,_messageVirtualDefaultHeightForRole(roleForIdx(idx))):defaultHeight;
};
if(total<=Math.max(threshold, keepTailCount)){
return {virtualized:false,start:0,end:total,topPad:0,bottomPad:0,total,tailStart};
}
const scrollTop=Math.max(0, Number(opts&&opts.scrollTop)||0);
const targetTop=Math.max(0, scrollTop-bufferPx);
const targetBottom=scrollTop+viewportHeight+bufferPx;
let start=0;
let offset=0;
while(start=tailStart){
return {virtualized:true,start:tailStart,end:tailStart,topPad:offset,bottomPad:0,total,tailStart};
}
let end=start;
let cursor=offset;
while(end=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;
_messageVirtualMeasurementRetryCount++;
requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true); });
}
function _markMessageVirtualMeasurementsSettled(windowMetrics){
_messageVirtualMeasurementCycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics);
_messageVirtualMeasurementRetryCount=0;
}
function _messageVirtualHeightEntryMatches(previousEntry, nextEntry){
return !!(
previousEntry&&nextEntry&&
previousEntry.m===nextEntry.m
);
}
function _messageVirtualHeightPrefixEntryMatches(previousEntry, nextEntry){
return !!(
previousEntry&&nextEntry&&
previousEntry.rawIdx===nextEntry.rawIdx&&
_messageVirtualHeightEntryMatches(previousEntry, nextEntry)
);
}
function _syncMessageVirtualHeightCache(visWithIdx){
const nextEntries=Array.isArray(visWithIdx)
? visWithIdx.map(entry=>entry?{rawIdx:entry.rawIdx,m:entry.m}:entry)
: [];
if(
_messageVirtualHeightCacheLen===S.messages.length &&
_messageVirtualHeightCacheSrc===S.messages &&
_messageVirtualHeightCacheEntries.length===nextEntries.length
) return;
const previousEntries=Array.isArray(_messageVirtualHeightCacheEntries)?_messageVirtualHeightCacheEntries:[];
const previousHeights=Array.isArray(_messageVirtualHeightCache)?_messageVirtualHeightCache.slice():[];
let nextHeights=null;
if(!previousEntries.length){
nextHeights=new Array(nextEntries.length);
}else if(!nextEntries.length){
_clearMessageVirtualHeightCache();
_messageVirtualHeightCacheLen=S.messages.length;
_messageVirtualHeightCacheSrc=S.messages;
return;
}else{
const sharedPrefix=Math.min(previousEntries.length,nextEntries.length);
let prefixMatches=true;
for(let i=0;i=previousEntries.length){
const prependedCount=nextEntries.length-previousEntries.length;
let suffixMatches=true;
for(let i=0;i0)||
(Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'))||
(Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0))
return 'tool_call';
return 'assistant';
}
return 'default';
}
function _currentMessageVirtualWindow(visWithIdx, keepTailCount){
_syncMessageVirtualHeightCache(visWithIdx);
const container=$('messages');
// #4325 opt-out: when the user disables transcript virtualization, always
// render the full transcript (no windowing). Mirrors the <=threshold path so
// every downstream consumer (render, anchor, prepend-delta) treats it as a
// plain non-virtualized list.
if(typeof window!=='undefined' && window._virtualizeTranscript===false){
const total=visWithIdx.length;
const tailStart=Math.max(0, total-Math.max(0, Number(keepTailCount)||0));
return {virtualized:false,start:0,end:total,topPad:0,bottomPad:0,total,tailStart};
}
return _messageVirtualWindow({
total:visWithIdx.length,
scrollTop:container?container.scrollTop:0,
viewportHeight:container?container.clientHeight:(_messageVirtualEstimatedRowHeight*6),
heights:_messageVirtualHeightCache,
defaultHeight:_messageVirtualEstimatedRowHeight,
roleForIdx:idx=>_messageVirtualRoleForEntry(visWithIdx[idx]),
keepTailCount,
});
}
function _messageVirtualPrependedHeightDelta(prependedRenderableCount){
const count=Math.max(0, Number(prependedRenderableCount)||0);
if(count<=0) return null;
const visWithIdx=_getVisibleMessagesWithIdx();
const virtualWindow=_currentMessageVirtualWindow(visWithIdx,_messageVirtualKeepTailCount());
if(!virtualWindow||!virtualWindow.virtualized) return null;
const limit=Math.min(count,_messageVirtualHeightCache.length);
let total=0;
for(let i=0;i0)?cached:_messageVirtualDefaultHeightForRole(_messageVirtualRoleForEntry(visWithIdx[i]));
}
return Math.max(0,Math.round(total));
}
function _messageVisibleIndexForRawIdx(rawIdx, visWithIdx){
const list=Array.isArray(visWithIdx)?visWithIdx:_getVisibleMessagesWithIdx();
for(let i=0;iencodeURIComponent(String(v))).join('|');
}
function _messageVisibleIndexForAnchorKey(anchorKey, visWithIdx){
const key=String(anchorKey||'');
if(!key) return -1;
const list=Array.isArray(visWithIdx)?visWithIdx:_getVisibleMessagesWithIdx();
for(let i=0;i0)?cached:_messageVirtualDefaultHeightForRole(_messageVirtualRoleForEntry(visWithIdx[i]));
}
const viewport=container?Math.max(0,Number(container.clientHeight)||0):0;
return Math.max(0,Math.round(offset-(viewport*0.35)));
}
function _messageVirtualKeepTailCount(){
return Math.min(_currentMessageRenderWindowSize(), MESSAGE_RENDER_WINDOW_DEFAULT);
}
function _captureMessageViewportAnchor(){
const container=$('messages');
if(!container) return null;
const containerRect=container.getBoundingClientRect();
const rows=Array.from(container.querySelectorAll('[data-msg-idx]'));
for(const row of rows){
const rawIdx=Number(row&&row.dataset&&row.dataset.msgIdx);
if(!Number.isFinite(rawIdx)) continue;
const rect=row.getBoundingClientRect();
if(rect.bottom>containerRect.top+1){
const sessionIdx=Number(row&&row.dataset&&row.dataset.sessionMsgIdx);
return {
rawIdx,
sessionIdx:Number.isFinite(sessionIdx)?sessionIdx:_messageSessionIndexForRawIdx(rawIdx),
key:row&&row.dataset?String(row.dataset.messageAnchorKey||''):'',
topOffset:rect.top-containerRect.top,
};
}
}
return null;
}
function _restoreMessageViewportAnchor(anchor, rawIdxDelta){
const container=$('messages');
if(!container||!anchor) return false;
const anchorKey=String(anchor.key||'');
let row=anchorKey?Array.from(container.querySelectorAll('[data-message-anchor-key]')).find(el=>el&&el.dataset&&el.dataset.messageAnchorKey===anchorKey):null;
if(row&&row.getClientRects&&row.getClientRects().length===0) row=null;
if(!row&&anchorKey) return false;
const sessionIdx=Number(anchor.sessionIdx);
const hasSessionIdx=Number.isFinite(sessionIdx);
if(!row&&hasSessionIdx) row=container.querySelector(`[data-session-msg-idx="${sessionIdx}"]`);
if(!row&&hasSessionIdx) return false;
const targetIdx=Number(anchor.rawIdx)+Number(rawIdxDelta||0);
if(!row&&Number.isFinite(targetIdx)) row=container.querySelector(`[data-msg-idx="${targetIdx}"]`);
if(!row) return false;
const containerRect=container.getBoundingClientRect();
const rect=row.getBoundingClientRect();
const targetTop=Number(anchor.topOffset)||0;
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
container.scrollTop+=(rect.top-containerRect.top)-targetTop;
if(typeof _deferClearProgrammaticScroll==='function') _deferClearProgrammaticScroll();
else requestAnimationFrame(()=>{ setTimeout(()=>{ _programmaticScroll=false; },0); });
return true;
}
let _messageViewportAnchorRemounting=false;
function _remountMessageViewportAnchor(anchor){
const container=$('messages');
if(!container||!anchor||_messageViewportAnchorRemounting) return false;
const anchorKey=String(anchor.key||'');
const visibleKeyNode=anchorKey
? Array.from(container.querySelectorAll('[data-message-anchor-key]')).find(node=>node&&node.dataset&&node.dataset.messageAnchorKey===anchorKey&&(!node.getClientRects||node.getClientRects().length>0))
: null;
if(visibleKeyNode) return true;
const sessionIdx=Number(anchor.sessionIdx);
const hasSessionIdx=Number.isFinite(sessionIdx);
if(!anchorKey&&hasSessionIdx&&container.querySelector(`[data-session-msg-idx="${sessionIdx}"]`)) return true;
const targetIdx=Number(anchor.rawIdx);
if(!anchorKey&&!hasSessionIdx&&Number.isFinite(targetIdx)&&container.querySelector(`[data-msg-idx="${targetIdx}"]`)) return true;
if(typeof _getVisibleMessagesWithIdx!=='function'||
typeof _messageVisibleIndexForRawIdx!=='function'||
typeof _messageVirtualScrollTopForVisibleIdx!=='function'||
typeof renderMessages!=='function') return false;
const visWithIdx=_getVisibleMessagesWithIdx();
let visIdx=anchorKey?_messageVisibleIndexForAnchorKey(anchorKey,visWithIdx):-1;
if(visIdx<0&&hasSessionIdx){
const rawFromSession=_messageRawIdxForSessionIndex(sessionIdx);
if(Number.isFinite(rawFromSession)) visIdx=_messageVisibleIndexForRawIdx(rawFromSession,visWithIdx);
}
if(visIdx<0&&Number.isFinite(targetIdx)) visIdx=_messageVisibleIndexForRawIdx(targetIdx,visWithIdx);
if(visIdx<0) return false;
// A virtualized anchor may be outside the current DOM. Scroll to its virtual
// row and render once so the semantic restore below has a real target.
_programmaticScroll=true;
container.scrollTop=_messageVirtualScrollTopForVisibleIdx(visWithIdx,visIdx,container);
_messageVirtualWindowKey='';
_messageViewportAnchorRemounting=true;
try{
renderMessages({preserveScroll:true});
}finally{
_messageViewportAnchorRemounting=false;
requestAnimationFrame(()=>{ setTimeout(()=>{ _programmaticScroll=false; },0); });
}
if(anchorKey){
return !!Array.from(container.querySelectorAll('[data-message-anchor-key]')).find(node=>node&&node.dataset&&node.dataset.messageAnchorKey===anchorKey&&(!node.getClientRects||node.getClientRects().length>0));
}
if(hasSessionIdx) return !!container.querySelector(`[data-session-msg-idx="${sessionIdx}"]`);
return Number.isFinite(targetIdx)&&!!container.querySelector(`[data-msg-idx="${targetIdx}"]`);
}
function _compensateScrollForMeasurementDelta(renderFn){
const container=$('messages');
if(!container) return renderFn();
const anchorBefore=_captureMessageViewportAnchor();
const scrollTopBefore=container.scrollTop;
container.classList.add('vscroll-measuring');
try{ renderFn(); }finally{ container.classList.remove('vscroll-measuring'); }
if(!anchorBefore) return;
if(scrollTopBefore<1){
const spacer=container.querySelector('[data-virtual-spacer="before"]');
if(!spacer||parseFloat(spacer.style.height||'0')<=0) return;
}
const row=container.querySelector(`[data-msg-idx="${anchorBefore.rawIdx}"]`);
if(!row) return;
const containerRect=container.getBoundingClientRect();
const rowRect=row.getBoundingClientRect();
const actualOffset=rowRect.top-containerRect.top;
const delta=actualOffset-anchorBefore.topOffset;
if(Math.abs(delta)<2) return;
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
container.scrollTop=scrollTopBefore+delta;
_lastScrollTop=container.scrollTop;
_deferClearProgrammaticScroll();
}
function _messageViewportIntersectsRenderedRow(){
const container=$('messages');
if(!container) return true;
const containerRect=container.getBoundingClientRect();
const rows=Array.from(container.querySelectorAll('[data-msg-idx]'));
for(const row of rows){
const rect=row.getBoundingClientRect();
if(rect.bottom>containerRect.top+1&&rect.top1){
_messageVirtualHeightCache[visibleIdx]=totalHeight;
changed=true;
}
measuredTotal+=totalHeight;
measuredCount++;
}
if(measuredCount>0){
_messageVirtualEstimatedRowHeight=Math.max(60, Math.round(measuredTotal/measuredCount));
}
if(changed){
_scheduleMessageVirtualMeasurementRefresh(virtualWindow);
}else{
_markMessageVirtualMeasurementsSettled(virtualWindow);
}
}
function _scheduleMessageVirtualizedRender(force){
const container=$('messages');
const inner=$('msgInner');
if(!container||!inner) return;
const visWithIdx=_getVisibleMessagesWithIdx();
const virtualWindow=_currentMessageVirtualWindow(visWithIdx,_messageVirtualKeepTailCount());
const nextKey=_messageVirtualWindowKeyFor(virtualWindow);
if(!force&&nextKey===_messageVirtualWindowKey) return;
if(!virtualWindow.virtualized){
_messageVirtualWindowKey=nextKey;
return;
}
if(_messageVirtualScrollRaf) return;
_messageVirtualScrollRaf=requestAnimationFrame(()=>{
_messageVirtualScrollRaf=0;
const liveVisWithIdx=_getVisibleMessagesWithIdx();
const liveWindow=_currentMessageVirtualWindow(liveVisWithIdx,_messageVirtualKeepTailCount());
const liveKey=_messageVirtualWindowKeyFor(liveWindow);
if(!force&&liveKey===_messageVirtualWindowKey) return;
if(_scrollbarDragActive){
_programmaticScroll=true;
_programmaticScrollSetAt=performance.now();
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); });
_deferClearProgrammaticScroll();
_messageVirtualWindowKey=liveKey;
return;
}
_msgNodeRecycleEnabled=true;
try{
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); });
}
finally{ _msgNodeRecycleEnabled=false; }
});
}
// ── renderMd / _renderUserFencedBlocks cache ──────────────────────────────
// Long sessions re-render the same messages on every renderMessages() call.
// Cache the rendered HTML so unchanged messages skip the expensive regex
// pipeline entirely. ~95% of messages are identical between renders.
const _renderCache = new Map();
const _renderCacheMax = 300;
function _clearRenderCache(){ _renderCache.clear(); }
function _renderCacheKey(text, isUser){
// Fold render_user_markdown state into user-message keys so toggling the
// setting invalidates cached plain-text renders (#3870).
const p = isUser ? (window._renderUserMarkdown ? 'um' : 'u') : 'a';
// Short content: use the full string as key (cheap Map lookup).
// Long content: length + prefix + suffix is good enough — collisions on
// 20-char prefix+suffix are vanishingly rare for chat messages.
if(text.length <= 500) return p + ':' + text;
return p + ':' + text.length + ':' + text.slice(0,20) + ':' + text.slice(-20);
}
function _getCachedRender(text, isUser){
const key = _renderCacheKey(text, isUser);
const hit = _renderCache.get(key);
if(hit !== undefined) return hit;
const rendered = isUser
? (window._renderUserMarkdown ? renderMd(text) : _renderUserFencedBlocks(text))
: renderMd(_stripXmlToolCallsDisplay(String(text)));
if(_renderCache.size > _renderCacheMax) _renderCache.clear();
_renderCache.set(key, rendered);
return rendered;
}
function _currentMessageRenderWindowSize(){
return Math.max(
MESSAGE_RENDER_WINDOW_DEFAULT,
Number(_messageRenderWindowSize)||MESSAGE_RENDER_WINDOW_DEFAULT
);
}
function _messageRenderableMessageCount(){
return _getVisibleMessagesWithIdx().length;
}
function _messageHiddenBeforeCount(){
return Math.max(0,_messageRenderableMessageCount()-_currentMessageRenderWindowSize());
}
function _isSessionEndlessScrollEnabled(){
return window._sessionEndlessScrollEnabled===true;
}
function _wireMessageWindowLoadEarlierButton(){
const indicator=$('loadOlderIndicator');
if(!indicator) return;
indicator.onclick=()=>{
if(typeof _loadOlderMessages==='function') _loadOlderMessages();
};
}
function _isSessionJumpButtonsEnabled(){
return window._sessionJumpButtonsEnabled===true;
}
function _applySessionNavigationPrefs(){
const container=$('messages');
if(container) container.classList.toggle('session-nav-enabled',_isSessionJumpButtonsEnabled());
_updateSessionStartJumpButton();
}
function _updateSessionStartJumpButton(){
const btn=$('jumpToSessionStartBtn');
const container=$('messages');
if(!btn||!container) return;
if(!_isSessionJumpButtonsEnabled()){
btn.style.display='none';
return;
}
const hasSession=!!(S&&S.session&&S.messages&&S.messages.length);
const awayFromStart=container.scrollTop>Math.max(240,container.clientHeight*0.35);
const hasScrollableHistory=container.scrollHeight>container.clientHeight+Math.max(240,container.clientHeight*0.35);
const canRevealStart=hasScrollableHistory||_messageHiddenBeforeCount()>0||!!(typeof _messagesTruncated!=='undefined'&&_messagesTruncated);
btn.style.display=(hasSession&&canRevealStart&&awayFromStart)?'flex':'none';
}
async function jumpToSessionStart(){
const container=$('messages');
if(!container||!S.session) return;
_scrollPinned=false;
_messageUserUnpinned=true;
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
try{
// During active streaming, skip full message load — API response won't
// include live messages from the current turn, and replacing S.messages
// would lose user/assistant/tool messages.
if(!(S.busy||S.activeStreamId)){
if(typeof _ensureAllMessagesLoaded==='function') await _ensureAllMessagesLoaded();
}
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount());
container.scrollTop=0;
_messageVirtualWindowKey='';
// During streaming, skip renderMessages — it rebuilds the DOM but tool card
// insertion is blocked by !S.busy, losing Activity until "done" fires.
if(!(S.busy||S.activeStreamId)){
renderMessages({ preserveScroll:true });
}
requestAnimationFrame(()=>{
container.scrollTop=0;
_updateSessionStartJumpButton();
_deferClearProgrammaticScroll();
});
}catch(e){
console.warn('jumpToSessionStart failed:',e);
_programmaticScroll=false;
}
}
function _userMessageDomId(rawIdx){
return `msg-user-${rawIdx}`;
}
function _questionJumpButtonHtml(questionRawIdx, assistantRawIdx){
if(typeof questionRawIdx!=='number'||questionRawIdx<0) return '';
const label=t('jump_to_question')||'Response';
const title=t('jump_to_question_label')||'Jump to the start of this response';
const aIdx=(typeof assistantRawIdx==='number'&&assistantRawIdx>=0)?assistantRawIdx:-1;
return `↑ ${esc(label)} `;
}
function _highlightQuestionRow(row){
if(!row) return;
row.classList.remove('msg-question-highlight');
void row.offsetWidth;
row.classList.add('msg-question-highlight');
window.setTimeout(()=>row.classList.remove('msg-question-highlight'),1800);
}
async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){
const container=$('messages');
if(!container||typeof questionRawIdx!=='number'||questionRawIdx<0) return;
const scrollToTarget=()=>{
const hasAssistant=typeof assistantRawIdx==='number'&&assistantRawIdx>=0;
if(hasAssistant){
// A single assistant rawIdx can render multiple segment nodes — some hidden
// (assistant-segment-worklog-source / assistant-segment-anchor are display:none).
// scrollIntoView() on a hidden node silently no-ops, so only treat a VISIBLE
// segment (getClientRects().length>0) as a successful target; otherwise fall
// through to the question-row fallback rather than suppressing it. (#3934)
const segs=container.querySelectorAll('[data-msg-idx="'+assistantRawIdx+'"]');
for(const seg of segs){
if(seg.getClientRects().length>0){
seg.scrollIntoView({block:'start',behavior:'smooth'});
return true;
}
}
}
const row=document.getElementById(_userMessageDomId(questionRawIdx));
if(!row) return false;
row.scrollIntoView({block:'center',behavior:'smooth'});
_highlightQuestionRow(row);
return true;
};
if(scrollToTarget()) return;
const visWithIdx=_getVisibleMessagesWithIdx();
const visibleIdx=_messageVisibleIndexForRawIdx(questionRawIdx, visWithIdx);
if(visibleIdx>=0){
_scrollPinned=false;
_messageUserUnpinned=true;
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
container.scrollTop=_messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container);
_messageVirtualWindowKey='';
renderMessages({ preserveScroll:true });
requestAnimationFrame(()=>{
if(!scrollToTarget()&&_messageHiddenBeforeCount()>0){
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount());
_messageVirtualWindowKey='';
renderMessages({ preserveScroll:true });
requestAnimationFrame(scrollToTarget);
}
_deferClearProgrammaticScroll();
});
return;
}
if(_messageHiddenBeforeCount()>0){
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount());
_messageVirtualWindowKey='';
renderMessages({ preserveScroll:true });
requestAnimationFrame(scrollToTarget);
}
}
const DASHBOARD_STATUS_TTL_MS=60000;
let _dashboardStatusCache=null;
let _dashboardStatusFetchedAt=0;
function _dashboardIsBrowserLoopback(){
const host=(window.location.hostname||'').replace(/^\[|\]$/g,'').toLowerCase();
return host==='127.0.0.1'||host==='localhost'||host==='::1';
}
function _dashboardBrowserUrl(status){
if(!status||!status.running) return '';
if(status.browser_url||status.url){
try{return new URL(status.browser_url||status.url).toString().replace(/\/$/,'');}
catch(_){}
}
if(!status.port) return '';
let source;
try{source=new URL('http://127.0.0.1:'+status.port);}
catch(_){return '';}
const browserHost=window.location.hostname||source.hostname;
const displayHost=browserHost.includes(':')&&!browserHost.startsWith('[')?'['+browserHost+']':browserHost;
return source.protocol+'//'+displayHost+':'+status.port;
}
function _applyDashboardStatus(status){
const running=!!(status&&status.running);
const url=running?_dashboardBrowserUrl(status):'';
const warning=running&&!_dashboardIsBrowserLoopback()?t('dashboard_loopback_warning'):'';
document.querySelectorAll('[data-dashboard-link]').forEach(btn=>{
btn.classList.toggle('dashboard-link-visible',running);
btn.style.display=running?'':'none';
btn.dataset.dashboardUrl=url;
const tipText=warning||t('tab_dashboard');
if(btn.hasAttribute('data-tooltip')){
// Sync the custom CSS tooltip and explicitly clear the native title so
// the slow ~1.5s native browser tooltip does not co-fire alongside the
// fast custom tooltip (#1775).
btn.setAttribute('data-tooltip',tipText);
if(btn.hasAttribute('title')) btn.removeAttribute('title');
} else {
btn.title=tipText;
}
btn.setAttribute('aria-label',tipText);
});
}
async function refreshDashboardStatus(force=false){
const now=Date.now();
if(!force&&_dashboardStatusCache&&(now-_dashboardStatusFetchedAt)1){
allImages = Array.from(siblings);
startIndex = allImages.indexOf(imgEl);
if(startIndex===-1) startIndex=0;
}
}
}
_openImgLightboxWithNav(src, alt, allImages, startIndex);
}
function _openMermaidLightbox(svgEl) {
if(!svgEl) return;
const lb = document.createElement('div');
lb.className = 'img-lightbox';
lb.setAttribute('role', 'dialog');
lb.setAttribute('aria-modal', 'true');
lb.setAttribute('aria-label', 'Mermaid diagram');
const clone = svgEl.cloneNode(true);
const idMap = new Map();
const idPrefix = 'mermaid-lightbox-'+Math.random().toString(36).slice(2,10)+'-';
const idNodes = [clone, ...clone.querySelectorAll('[id]')].filter(el => el.id);
idNodes.forEach(el => {
const nextId = idPrefix + el.id;
idMap.set(el.id, nextId);
el.id = nextId;
});
if(idMap.size){
const refAttrs = ['href','xlink:href','fill','stroke','filter','clip-path','mask','marker-start','marker-mid','marker-end','aria-labelledby','aria-describedby'];
[clone, ...clone.querySelectorAll('*')].forEach(el => {
refAttrs.forEach(attr => {
const value = el.getAttribute(attr);
if(!value) return;
let nextValue = value.replace(/url\(#([^)]+)\)/g, (match, refId) => idMap.has(refId) ? `url(#${idMap.get(refId)})` : match);
if(nextValue.startsWith('#') && idMap.has(nextValue.slice(1))){
nextValue = '#'+idMap.get(nextValue.slice(1));
}
if(nextValue !== value){
el.setAttribute(attr, nextValue);
}
});
});
clone.querySelectorAll('style').forEach(styleEl => {
let styleText = styleEl.textContent || '';
idMap.forEach((nextId, originalId) => {
const escapedId = originalId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
styleText = styleText.replace(new RegExp(`url\\(#${escapedId}\\)`, 'g'), `url(#${nextId})`);
styleText = styleText.replace(new RegExp(`(^|[^\\w-])#${escapedId}(?=$|[^\\w-])`, 'g'), (match, prefix) => `${prefix}#${nextId}`);
});
styleEl.textContent = styleText;
});
}
clone.classList.add('mermaid-lightbox-svg');
clone.removeAttribute('width');
clone.removeAttribute('height');
clone.onclick = e => e.stopPropagation();
const cls = document.createElement('button');
cls.className = 'img-lightbox-close';
cls.setAttribute('aria-label', 'Close');
cls.textContent = '×';
cls.onclick = () => _closeImgLightbox(lb);
lb.appendChild(clone);
lb.appendChild(cls);
lb.onclick = () => _closeImgLightbox(lb);
lb._keyHandler = e => {
if(e.key==='Escape') _closeImgLightbox(lb);
};
document.body.appendChild(lb);
document.addEventListener('keydown', lb._keyHandler);
}
function _openImgLightboxWithNav(src, alt, images, index) {
const lb = document.createElement('div');
lb.className = 'img-lightbox';
lb.setAttribute('role', 'dialog');
lb.setAttribute('aria-modal', 'true');
lb.setAttribute('aria-label', alt || 'Image');
const img = document.createElement('img');
img.src = src;
img.alt = alt || '';
img.onclick = e => e.stopPropagation();
const cls = document.createElement('button');
cls.className = 'img-lightbox-close';
cls.setAttribute('aria-label', 'Close');
cls.textContent = '×';
cls.onclick = () => _closeImgLightbox(lb);
lb.appendChild(img);
lb.appendChild(cls);
// Prev/Next navigation — store index and images on lb so a single set of
// handlers reads live values without closure churn on every nav.
lb._navIndex = index;
lb._navImages = (images && images.length>1) ? images : null;
if(lb._navImages){
const prevBtn = document.createElement('button');
prevBtn.className = 'img-lightbox-nav img-lightbox-nav-prev';
prevBtn.setAttribute('aria-label', 'Previous image');
prevBtn.innerHTML = '‹';
prevBtn.onclick = e => { e.stopPropagation(); _navigateLightbox(lb, -1); };
lb.appendChild(prevBtn);
const nextBtn = document.createElement('button');
nextBtn.className = 'img-lightbox-nav img-lightbox-nav-next';
nextBtn.setAttribute('aria-label', 'Next image');
nextBtn.innerHTML = '›';
nextBtn.onclick = e => { e.stopPropagation(); _navigateLightbox(lb, 1); };
lb.appendChild(nextBtn);
lb._counterEl = document.createElement('div');
lb._counterEl.className = 'img-lightbox-counter';
lb.appendChild(lb._counterEl);
lb._counterEl.textContent = (index+1) + ' / ' + images.length;
}
lb.onclick = () => _closeImgLightbox(lb);
document.body.appendChild(lb);
// Single keyboard handler — reads lb._navX live, no remove/add churn.
lb._keyHandler = e => {
if(e.key==='Escape'){ _closeImgLightbox(lb); return; }
if(lb._navImages){
if(e.key==='ArrowLeft'){ e.preventDefault(); _navigateLightbox(lb, -1); }
if(e.key==='ArrowRight'){ e.preventDefault(); _navigateLightbox(lb, 1); }
}
};
document.addEventListener('keydown', lb._keyHandler);
}
function _navigateLightbox(lb, direction) {
const images = lb._navImages;
if(!images) return;
const newIndex = lb._navIndex + direction;
if(newIndex<0 || newIndex>=images.length) return;
lb._navIndex = newIndex;
const nextImg = images[newIndex];
const lbImg = lb.querySelector('img');
if(!lbImg) return;
lbImg.src = nextImg.src;
lbImg.alt = nextImg.alt || '';
lb.setAttribute('aria-label', nextImg.alt || 'Image');
// Update counter via stored reference — no DOM query.
if(lb._counterEl) lb._counterEl.textContent = (newIndex+1) + ' / ' + images.length;
}
function _closeImgLightbox(lb) {
if(!lb || !lb.parentNode) return;
document.removeEventListener('keydown', lb._keyHandler);
lb.style.animation = 'lb-in .12s ease reverse';
setTimeout(() => lb.parentNode && lb.parentNode.removeChild(lb), 120);
}
document.addEventListener('click', e => {
if(!e.target || !e.target.closest) return;
const sessionLink=e.target.closest('a.session-link[href]');
if(sessionLink){
const href=sessionLink.getAttribute('href')||'';
const m=href.match(/(?:^|\/)session\/([^?#]+)/i);
if(m&&typeof loadSession==='function'){
e.preventDefault();
try{loadSession(decodeURIComponent(m[1]));}catch(_){loadSession(m[1]);}
}
return;
}
const workspaceLink=e.target.closest('a[href^="#workspace="]');
if(workspaceLink){
e.preventDefault();
const href=workspaceLink.getAttribute('href')||'';
try{
const rel=decodeURIComponent(href.slice('#workspace='.length));
if(rel && typeof openArtifactPath==='function') openArtifactPath(rel);
}catch(_){}
return;
}
// Message-attached images (already wired since v0.50.x).
let img = e.target.closest('.msg-media-img');
if(img){ _openImgLightbox(img); return; }
const mermaidSvg = e.target.closest('.mermaid-rendered svg');
if(mermaidSvg){ _openMermaidLightbox(mermaidSvg); return; }
// Composer attach-tray image thumbnails — click any pasted/dropped image
// chip to lightbox-zoom it before sending. Excludes audio/video chips,
// which keep their inline media controls. SVG thumbnails (.attach-thumb--svg)
// are still images visually, so they qualify.
img = e.target.closest('.attach-thumb');
if(img && img.tagName === 'IMG'){
_openImgLightbox(img);
return;
}
});
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
const _PDF_EXTS=/\.pdf$/i;
const _HTML_EXTS=/\.(html?|htm)$/i;
const _ARCHIVE_EXTS=/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i;
const _SVG_EXTS=/\.svg$/i;
const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm|oga)$/i;
const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
const _CSV_EXTS=/\.csv$/i;
const _EXCALIDRAW_EXTS=/\.excalidraw$/i;
// ── Media playback speed controls ─────────────────────────────────────────
const MEDIA_PLAYBACK_RATES=[0.5,0.75,1,1.25,1.5,2];
const MEDIA_PLAYBACK_STORAGE_KEY='hermes-media-playback-rate';
function _getStoredMediaPlaybackRate(){
try{
const raw=localStorage.getItem(MEDIA_PLAYBACK_STORAGE_KEY);
const rate=Number(raw);
return MEDIA_PLAYBACK_RATES.includes(rate)?rate:1;
}catch(_){return 1;}
}
function _setStoredMediaPlaybackRate(rate){
if(!MEDIA_PLAYBACK_RATES.includes(rate)) return;
try{localStorage.setItem(MEDIA_PLAYBACK_STORAGE_KEY,String(rate));}catch(_){}
}
function _syncMediaSpeedButtons(editor, rate){
if(!editor) return;
editor.querySelectorAll('.media-speed-btn').forEach(b=>{
const active=Number(b.dataset.rate)===rate;
b.classList.toggle('active',active);
b.setAttribute('aria-pressed',active?'true':'false');
});
}
function _applyMediaPlaybackRate(media, rate=_getStoredMediaPlaybackRate()){
if(!media) return;
media.playbackRate=rate;
_syncMediaSpeedButtons(media.closest('.msg-media-editor,.preview-media-wrap'),rate);
}
function _mediaKindForName(name=''){
const clean=String(name||'').split('?')[0].toLowerCase();
if(_AUDIO_EXTS.test(clean)) return 'audio';
if(_VIDEO_EXTS.test(clean)) return 'video';
if(_IMAGE_EXTS.test(clean)) return 'image';
return '';
}
function _mediaSpeedControlsHtml(kind, label){
const safeLabel=esc(label||kind||'media');
const current=_getStoredMediaPlaybackRate();
return `${MEDIA_PLAYBACK_RATES.map(rate=>`${rate}× `).join('')}
`;
}
function _mediaPlayerHtml(kind, src, name, extra=''){
const safeName=esc(name||'media');
const safeSrc=esc(src);
const tag=kind==='video'
? ` `
: ` `;
return ``;
}
function _renderAttachmentHtml(fname, url){
const kind=_mediaKindForName(fname);
if(kind==='image') return ` `;
if(kind==='audio'||kind==='video') return _mediaPlayerHtml(kind,url,fname);
if(_HTML_EXTS.test(fname)){
const inlineUrl=url+(String(url).includes('?')?'&':'?')+'inline=1';
return `${li('file-code',12)} ${esc(fname)} `;
}
return `${li('paperclip',12)} ${esc(fname)}
`;
}
document.addEventListener('click', e => {
const btn=e.target&&e.target.closest?e.target.closest('.media-speed-btn'):null;
if(!btn) return;
const editor=btn.closest('.msg-media-editor,.preview-media-wrap');
if(!editor) return;
const media=editor.querySelector('audio,video');
if(!media) return;
const rate=Number(btn.dataset.rate)||1;
_setStoredMediaPlaybackRate(rate);
_applyMediaPlaybackRate(media,rate);
});
document.addEventListener("loadedmetadata", e=>{
if(e.target&&e.target.matches&&e.target.matches('.msg-media-player,audio,video')){
_applyMediaPlaybackRate(e.target);
}
},true);
function _initMediaPlaybackObserver(){
if(!document.body||window._mediaPlaybackObserver) return;
window._mediaPlaybackObserver=new MutationObserver(records=>{
for(const rec of records){
for(const node of rec.addedNodes||[]){
if(!node||node.nodeType!==1) continue;
const media=[];
if(node.matches&&node.matches('audio,video')) media.push(node);
if(node.querySelectorAll) media.push(...node.querySelectorAll('audio,video'));
media.forEach(m=>_applyMediaPlaybackRate(m));
}
}
});
window._mediaPlaybackObserver.observe(document.body,{childList:true,subtree:true});
document.querySelectorAll('audio,video').forEach(m=>_applyMediaPlaybackRate(m));
}
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',_initMediaPlaybackObserver);
else _initMediaPlaybackObserver();
setTimeout(_initMediaPlaybackObserver,0);
// ── Ambient provider quota indicator (#1766) ────────────────────────────────
let _providerQuotaRefreshInFlight=false;
function _formatQuotaMoneyShort(value){
const n=Number(value);
if(!Number.isFinite(n)) return '';
if(Math.abs(n)>=100) return '$'+n.toFixed(0);
if(Math.abs(n)>=10) return '$'+n.toFixed(1);
return '$'+n.toFixed(2);
}
function _formatQuotaPercentShort(value){
const n=Number(value);
if(!Number.isFinite(n)) return '';
return Math.max(0,Math.min(100,n)).toFixed(0)+'%';
}
function _providerQuotaIndicatorText(status){
if(!status||status.status!=='available') return null;
const provider=status.display_name||status.provider||'Provider';
const accountLimits=status.account_limits||null;
if(accountLimits&&Array.isArray(accountLimits.windows)&&accountLimits.windows.length){
const w=accountLimits.windows.find(x=>x&&Number.isFinite(Number(x.remaining_percent)))||accountLimits.windows[0];
const remaining=_formatQuotaPercentShort(w&&w.remaining_percent);
if(remaining) return {label:provider+' '+remaining, title:(status.message||'Provider usage loaded')+' — '+remaining+' remaining'};
}
const quota=status.quota||null;
if(quota){
const remaining=_formatQuotaMoneyShort(quota.limit_remaining);
const used=_formatQuotaMoneyShort(quota.usage);
const limit=_formatQuotaMoneyShort(quota.limit);
if(remaining){
const parts=[];
if(used) parts.push('used '+used);
if(limit) parts.push('limit '+limit);
return {label:provider+' '+remaining, title:(status.message||'Provider quota loaded')+(parts.length?' — '+parts.join(' · '):'')};
}
}
return null;
}
function renderProviderQuotaIndicator(status){
const chip=$('providerQuotaChip');
const label=$('providerQuotaChipLabel');
if(!chip||!label) return;
// Hide entirely when the user has disabled the ambient quota chip in Settings.
// Default is off (window._showQuotaChip defaults to false in boot.js) so users
// never see the chip unless they opt in.
if(window._showQuotaChip!==true){
chip.hidden=true;
label.textContent='';
chip.removeAttribute('title');
return;
}
const text=_providerQuotaIndicatorText(status);
if(!text||status.status!=='available'||(!status.quota&&!status.account_limits)){
chip.hidden=true;
label.textContent='';
chip.removeAttribute('title');
return;
}
label.textContent=text.label;
chip.title=text.title;
chip.hidden=false;
}
async function refreshProviderQuotaIndicator(){
// Short-circuit before the fetch when the chip is disabled — no point asking
// the server for quota data the UI will throw away.
if(window._showQuotaChip!==true){
const chip=$('providerQuotaChip');
if(chip){chip.hidden=true;chip.removeAttribute('title');}
return;
}
if(_providerQuotaRefreshInFlight) return;
_providerQuotaRefreshInFlight=true;
try{
const status=await api('/api/provider/quota');
renderProviderQuotaIndicator(status);
}catch(_e){
renderProviderQuotaIndicator(null);
}finally{
_providerQuotaRefreshInFlight=false;
}
}
window.addEventListener('visibilitychange',()=>{
if(document.visibilityState==='visible'&&typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
});
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
let _dynamicModelLabels={};
window._configuredModelBadges=window._configuredModelBadges||{};
const MODEL_STATE_KEY='hermes-webui-model-state';
const PENDING_SESSION_MODEL_PREFIX='hermes-webui-pending-session-model:';
const PENDING_SESSION_MODEL_MAX_AGE_MS=10*60*1000;
// ── Smart model resolver ────────────────────────────────────────────────────
// Finds the best matching option value in a for a given model ID.
// Handles mismatches like 'claude-sonnet-4-6' vs 'anthropic/claude-sonnet-4.6'.
// When a preferred provider is supplied, duplicate normalized IDs prefer that
// provider's option so Settings/profile rehydration doesn't snap back to the
// first colliding entry.
function _getOptionProviderId(opt){
if(!opt) return '';
if(opt.dataset && opt.dataset.provider) return opt.dataset.provider;
const group=opt.parentElement;
if(group && group.tagName==='OPTGROUP' && group.dataset && group.dataset.provider){
return group.dataset.provider;
}
const value=String(opt.value||'');
if(value.startsWith('@') && value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
return '';
}
function _providerFromModelValue(modelId){
const value=String(modelId||'').trim();
if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
return '';
}
function _providerSkipsModelMismatchWarning(providerId){
const p=String(providerId||'').toLowerCase();
return !p||p==='custom'||p.startsWith('custom:')||p==='openrouter';
}
function _providerDefersMissingModelFallback(providerId){
const p=String(providerId||'').toLowerCase();
// Named custom providers and OpenRouter can legitimately route vendor-prefixed
// model IDs that are not present in the current static catalog. Do not
// silently rewrite those sessions to the default just because the option has
// not been hydrated yet (#2405).
return p.startsWith('custom:')||p==='openrouter';
}
function _modelStateForSelect(sel, modelId){
const value=String(modelId||'').trim();
if(!value) return {model:'',model_provider:null};
const explicitProvider=_providerFromModelValue(value);
if(explicitProvider) return {model:value,model_provider:explicitProvider};
const opt=sel&&sel.selectedOptions&&sel.selectedOptions[0];
const provider=String(_getOptionProviderId(opt)||'').trim();
return {model:value,model_provider:(provider&&provider!=='default')?provider:null};
}
function _captureModelDropdownSelection(sel){
if(!sel||!sel.value) return null;
try{
const state=_modelStateForSelect(sel,sel.value);
if(state&&state.model) return state;
}catch(_){}
return {model:String(sel.value||''),model_provider:null};
}
function _modelProviderForSend(modelId){
const sessionProvider=(S&&S.session&&S.session.model_provider)||null;
if(sessionProvider) return sessionProvider;
const model=String(modelId||'').trim();
if(!model) return null;
const explicitProvider=typeof _providerFromModelValue==='function'
? _providerFromModelValue(model)
: '';
if(explicitProvider) return explicitProvider;
const sel=typeof $==='function' ? $('modelSelect') : null;
if(sel&&String(sel.value||'').trim()===model&&typeof _modelStateForSelect==='function'){
try{
const dropdownState=_modelStateForSelect(sel,sel.value);
if(dropdownState&&String(dropdownState.model||'').trim()===model){
return dropdownState.model_provider||null;
}
}catch(_){}
}
if(typeof _readPersistedModelState==='function'){
try{
const persisted=_readPersistedModelState();
if(persisted&&String(persisted.model||'').trim()===model){
return persisted.model_provider||null;
}
}catch(_){}
}
return null;
}
function _reconcileModelDropdownSelection(sel,data,previousState,opts){
if(!sel) return null;
const activeSession=(typeof S!=='undefined'&&S&&S.session)?S.session:null;
// Fresh boot is the only path where the profile/server default intentionally
// beats a browser-persisted or static fallback value. Every other model-list
// rebuild should preserve the loaded session model or the user's current
// in-page selection when it still exists in the refreshed catalog.
const shouldApplyBootDefault=!!(opts&&opts.preferProfileDefaultOnFreshBoot);
// Helper: apply the requested model, but if it is missing from the current
// catalog (cross-provider selection after a partial/timed-out rebuild), inject
// it as a custom option instead of returning null and letting the browser
// silently snap to the first . _ensureModelOptionInDropdown already
// tries _applyModelToDropdown first, so delegate to it (single scan) and keep
// the plain-apply fallback for the unlikely case it is unavailable.
const _applyOrEnsure = function(modelId, providerId) {
if (typeof _ensureModelOptionInDropdown === 'function') {
return _ensureModelOptionInDropdown(modelId, sel, providerId);
}
return _applyModelToDropdown(modelId, sel, providerId);
};
if(shouldApplyBootDefault && data&&data.default_model && !(activeSession&&activeSession.model)){
return _applyOrEnsure(data.default_model, data.active_provider||null);
}
if(activeSession&&activeSession.model){
return _applyOrEnsure(activeSession.model, activeSession.model_provider||null);
}
if(previousState&&previousState.model){
return _applyOrEnsure(previousState.model, previousState.model_provider||null);
}
return null;
}
function _providerQualifiedModelValueForSelect(sel, modelId){
return _modelStateForSelect(sel,modelId).model;
}
function _readPersistedModelState(){
try{
const raw=localStorage.getItem(MODEL_STATE_KEY);
if(raw){
const parsed=JSON.parse(raw);
if(parsed&&parsed.model){
return {
model:String(parsed.model||''),
model_provider:parsed.model_provider?String(parsed.model_provider):(_providerFromModelValue(parsed.model)||null),
};
}
}
}catch(_){}
const legacy=localStorage.getItem('hermes-webui-model');
if(!legacy) return null;
return {model:legacy,model_provider:_providerFromModelValue(legacy)||null};
}
function _writePersistedModelState(model, modelProvider){
const value=String(model||'').trim();
const provider=modelProvider?String(modelProvider).trim():(_providerFromModelValue(value)||null);
if(!value){
localStorage.removeItem('hermes-webui-model');
localStorage.removeItem(MODEL_STATE_KEY);
return;
}
localStorage.setItem('hermes-webui-model', value);
try{
localStorage.setItem(MODEL_STATE_KEY, JSON.stringify({model:value,model_provider:provider||null}));
}catch(_){}
}
function _clearPersistedModelState(){
localStorage.removeItem('hermes-webui-model');
localStorage.removeItem(MODEL_STATE_KEY);
}
function _pendingSessionModelKey(sessionId){
return PENDING_SESSION_MODEL_PREFIX+String(sessionId||'');
}
function _rememberPendingSessionModel(sessionId, model, modelProvider){
const sid=String(sessionId||'').trim();
const value=String(model||'').trim();
if(!sid||!value) return;
const provider=modelProvider?String(modelProvider).trim():(_providerFromModelValue(value)||null);
try{
sessionStorage.setItem(_pendingSessionModelKey(sid), JSON.stringify({
model:value,
model_provider:provider||null,
saved_at:Date.now(),
}));
}catch(_){}
}
function _readPendingSessionModel(sessionId){
const sid=String(sessionId||'').trim();
if(!sid) return null;
try{
const raw=sessionStorage.getItem(_pendingSessionModelKey(sid));
if(!raw) return null;
const parsed=JSON.parse(raw);
const model=String(parsed&&parsed.model||'').trim();
if(!model){
sessionStorage.removeItem(_pendingSessionModelKey(sid));
return null;
}
const savedAt=Number(parsed.saved_at||0);
if(savedAt&&Date.now()-savedAt>PENDING_SESSION_MODEL_MAX_AGE_MS){
sessionStorage.removeItem(_pendingSessionModelKey(sid));
return null;
}
return {
model,
model_provider:parsed&&parsed.model_provider?String(parsed.model_provider):(_providerFromModelValue(model)||null),
};
}catch(_){
try{sessionStorage.removeItem(_pendingSessionModelKey(sid));}catch(__){}
return null;
}
}
function _clearPendingSessionModel(sessionId){
const sid=String(sessionId||'').trim();
if(!sid) return;
try{sessionStorage.removeItem(_pendingSessionModelKey(sid));}catch(_){}
}
function _applyPendingSessionModelForSession(sessionId){
if(!S.session||S.session.session_id!==sessionId) return false;
const pending=_readPendingSessionModel(sessionId);
if(!pending) return false;
const sameModel=String(S.session.model||'')===pending.model;
const sameProvider=String(S.session.model_provider||'')===String(pending.model_provider||'');
if(sameModel&&sameProvider){
_clearPendingSessionModel(sessionId);
return false;
}
S.session.model=pending.model;
S.session.model_provider=pending.model_provider||null;
const retry=_persistSessionModelCorrection(pending.model,pending.model_provider||null,{propagateErrors:true});
if(retry&&typeof retry.then==='function'){
retry.then(()=>_clearPendingSessionModel(sessionId)).catch(()=>{});
}
return true;
}
function _findModelInDropdown(modelId, sel, preferredProviderId){
if(!modelId||!sel) return null;
const options=Array.from(sel.options);
const opts=options.map(o=>o.value);
// 0. Exact match — highest priority when it doesn't conflict with a
// cross-provider preference (#3360, guarded for #1228/#1313).
// When all models share the same provider (e.g. a custom proxy),
// normalization can collapse distinct multi-slash IDs to the same key
// and options.find() returns whichever appears first in the DOM instead
// of the exact value. But when the exact option belongs to a *different*
// provider than the preferred one, we must fall through to the provider-
// aware match so rehydration doesn't snap to the wrong provider row.
if(opts.includes(modelId)){
const exactOpt=options.find(o=>o.value===modelId);
const exactProv=exactOpt?_getOptionProviderId(exactOpt).toLowerCase():'';
const pref=String(preferredProviderId||'').toLowerCase();
if(!pref || !exactProv || exactProv===pref) return modelId;
}
// 1. Normalize: lowercase, strip namespace prefix, replace hyphens→dots.
// Also strip @provider: prefix from deduplicated model IDs (#1228, #1313).
const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/^@([^:]+:)+/,'').replace(/-/g,'.');
const target=norm(modelId);
let explicitProvider='';
const rawModel=String(modelId||'');
if(rawModel.startsWith('@')&&rawModel.includes(':')){
explicitProvider=rawModel.slice(1,rawModel.lastIndexOf(':'));
}
const preferred=String(preferredProviderId||explicitProvider||'').toLowerCase();
if(preferred){
const providerMatch=options.find(o=>norm(o.value)===target && _getOptionProviderId(o).toLowerCase()===preferred);
if(providerMatch) return providerMatch.value;
}
// 2. Normalized match
const exact=opts.find(o=>norm(o)===target);
if(exact) return exact;
// If the request is provider-qualified (either explicit @provider:model or
// a slash-qualified vendor/model id), do NOT fuzzy-match a sibling model
// once exact/provider-aware lookup failed. Returning null lets the caller
// preserve the raw typed value instead of snapping to the closest catalog
// entry. This keeps uncatalogued models routable instead of silently turning
// them into a nearby curated sibling.
if(rawModel.startsWith('@')||rawModel.includes('/')) return null;
// 3. Prefix/substring: require the candidate to start with the FULL normalized target
// (not a truncated base). This avoids false matches like gpt.5.5 → gpt.5.4.mini (#1188).
// Only fall back to the shorter base form if target itself is very short (a bare root
// like "gpt" or "claude") where stripping would be a no-op anyway.
const base=target.replace(/\.\d+$/,''); // strip trailing version number
const useBase=base.length<=4||base===target; // bare root — stripping changed nothing meaningful
const prefixTarget=useBase?base:target;
// When the typed target is a COMPLETE versioned name (ends in a digit, e.g.
// "mimo-v2.5" → norm "mimo.v2.5"), a prefix hit on a longer option is only
// legitimate if the extra text continues the VERSION ("." + digit, e.g.
// mimo.v2 → mimo.v2.5...). If the extra text is a variant/tier suffix
// ("." + non-digit, e.g. mimo.v2.5.pro from "mimo-v2.5-pro"), the user asked
// for the base model that simply isn't in the catalog — do NOT silently snap
// them to the -pro/-flash tier (and a different price tier). Let resolution
// fall through to null so the caller reports no-match instead. (#3368)
const targetEndsInVersion=/\d$/.test(target);
const partial=opts.find(o=>{
const no=norm(o);
if(!no.startsWith(prefixTarget)) return false;
if(targetEndsInVersion && no!==target){
const rest=no.slice(target.length);
// reject "." + non-digit (variant/tier suffix); allow "" or "." + digit (version continuation)
if(rest && !/^\.\d/.test(rest)) return false;
}
return true;
});
return partial||null;
}
// Set the model picker to the best match for modelId.
// Returns the resolved value that was actually set, or null if nothing matched.
function _refreshOpenModelDropdown(){
const dd=$('composerModelDropdown');
if(dd&&dd.classList&&dd.classList.contains('open')&&typeof renderModelDropdown==='function'){
renderModelDropdown();
if(typeof _positionModelDropdown==='function') _positionModelDropdown();
}
}
function _applyModelToDropdown(modelId, sel, preferredProviderId, opts){
if(!modelId||!sel) return null;
const currentState=(sel.id==='modelSelect'&&typeof _modelStateForSelect==='function')
? _modelStateForSelect(sel, sel.value)
: null;
const resolved=_findModelInDropdown(modelId,sel,preferredProviderId);
if(resolved){
sel.value=resolved;
if(sel.id==='modelSelect'){
const resolvedState=typeof _modelStateForSelect==='function'
? _modelStateForSelect(sel, resolved)
: {model:resolved,model_provider:preferredProviderId||null};
const pickerChanged= !!(opts&&opts.forceRefresh) || !currentState
|| String(currentState.model||'')!==String(resolvedState.model||'')
|| String(currentState.model_provider||'')!==String(resolvedState.model_provider||'');
if(typeof syncModelChip==='function') syncModelChip();
if(pickerChanged) _refreshOpenModelDropdown();
}
return resolved;
}
return null;
}
function _ensureModelOptionInDropdown(modelId, sel, preferredProviderId){
if(!modelId||!sel) return null;
const applied=_applyModelToDropdown(modelId,sel,preferredProviderId);
if(applied) return applied;
const value=modelId;
const opt=document.createElement('option');
opt.value=modelId;
opt.textContent=typeof getModelLabel==='function'?getModelLabel(modelId):modelId;
opt.dataset.custom='1';
const badge=(window._configuredModelBadges||{})[value];
if(badge&&badge.provider) opt.dataset.provider=badge.provider;
const provider=preferredProviderId||(badge&&badge.provider)||_providerFromModelValue(modelId)||'';
if(provider) opt.dataset.provider=provider;
sel.appendChild(opt);
sel.value=modelId;
if(sel.id==='modelSelect'){
if(typeof syncModelChip==='function') syncModelChip();
_refreshOpenModelDropdown();
}
return modelId;
}
function _modelStateFromAppliedDropdown(sel, modelValue){
const state=(typeof _modelStateForSelect==='function')
? _modelStateForSelect(sel,modelValue)
: {model:modelValue,model_provider:null};
return {model:state.model||modelValue,model_provider:state.model_provider||null};
}
function _persistSessionModelCorrection(model, provider, opts){
if(!S.session) return;
const request=fetch(new URL('api/session/update',document.baseURI||location.href).href,{
method:'POST',credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:model,model_provider:provider||null})
});
return opts&&opts.propagateErrors ? request : request.catch(()=>{});
}
let _modelDropdownRequestSeq=0;
function _applySessionModelFallback(sel){
if(!sel) return null;
const configuredDefault=String(window._defaultModel||'').trim();
if(configuredDefault){
const appliedDefault=_applyModelToDropdown(configuredDefault,sel,window._activeProvider||null);
if(appliedDefault) return _modelStateFromAppliedDropdown(sel,appliedDefault);
}
const first=sel.querySelector('optgroup > option, option');
if(first){
sel.value=first.value;
if(sel.id==='modelSelect'){
if(typeof syncModelChip==='function') syncModelChip();
_refreshOpenModelDropdown();
}
return _modelStateFromAppliedDropdown(sel,first.value);
}
return null;
}
async function populateModelDropdown(opts={}){
const sel=$('modelSelect');
if(!sel) return;
if(typeof _modelDropdownRequestSeq!=='number') _modelDropdownRequestSeq=0;
const requestSeq=++_modelDropdownRequestSeq;
try{
const modelsUrl=new URL('api/models',document.baseURI||location.href);
if(opts&&opts.freshness) modelsUrl.searchParams.set('freshness',opts.freshness);
const _modelsRes=await fetch(modelsUrl.href,{credentials:'include'});
if(requestSeq!==_modelDropdownRequestSeq) return;
if(_redirectIfUnauth(_modelsRes)) return;
const data=await _modelsRes.json();
if(requestSeq!==_modelDropdownRequestSeq) return;
// Store active provider globally so the send path can warn on mismatch
window._activeProvider=data.active_provider||null;
// Store default model so newSession() can apply it (#872).
// Per-page-load — not synced across browser tabs.
window._defaultModel=data.default_model||null;
window._configuredModelBadges=data.configured_model_badges||{};
// Keep the g.extra_models label hydration path below in this function; tests
// assert populateModelDropdown preserves that full-catalog label contract.
window._modelEndpointErrors={};
const _synthGroupsFromConfigured=()=>{
const badgeMap=window._configuredModelBadges||{};
const grouped=new Map();
const addModel=(providerId,modelId)=>{
const pid=String(providerId||'configured').trim()||'configured';
const mid=String(modelId||'').trim();
if(!mid) return;
if(!grouped.has(pid)) grouped.set(pid,[]);
const arr=grouped.get(pid);
if(arr.some(m=>m.id===mid)) return;
arr.push({id:mid,label:getModelLabel(mid)});
};
for(const [modelId,badge] of Object.entries(badgeMap)){
const mid=String(modelId||'').trim();
// Prefer canonical IDs only; skip derived aliases such as
// @provider:model and provider/model to avoid noisy duplicates.
if(!mid||mid.startsWith('@')||mid.includes('/')) continue;
const provider=(badge&&badge.provider)||'configured';
addModel(provider,mid);
}
if(grouped.size===0&&data&&data.default_model){
addModel(data.active_provider||'configured',data.default_model);
}
const groups=[];
for(const [providerId,models] of grouped.entries()){
const display=(String(providerId).startsWith('custom:')
? String(providerId).slice('custom:'.length)
: String(providerId))||'Configured';
groups.push({provider:display,provider_id:providerId,models});
}
return groups;
};
const groups=(Array.isArray(data.groups)&&data.groups.length)
? data.groups
: _synthGroupsFromConfigured();
if(!groups.length) return; // no server groups and no configured fallback
const previousSelection=_captureModelDropdownSelection(sel);
// Clear existing options
sel.innerHTML='';
_dynamicModelLabels={};
for(const g of groups){
const og=document.createElement('optgroup');
og.label=g.provider;
if(g.provider_id) og.dataset.provider=g.provider_id;
if(g.models_endpoint_error){
const errorKey=g.provider_id||g.provider||'';
og.dataset.modelsEndpointError=JSON.stringify(g.models_endpoint_error);
if(errorKey) window._modelEndpointErrors[errorKey]=g.models_endpoint_error;
}
for(const m of (Array.isArray(g.models)?g.models:[])){
const opt=document.createElement('option');
opt.value=m.id;
opt.textContent=m.label;
og.appendChild(opt);
_dynamicModelLabels[m.id]=m.label||m.id;
}
// Hydrate the label map from extra_models too (the catalog tail that
// doesn't render as entries when the picker is capped — see
// _build_nous_featured_set in api/config.py for the rationale). This
// keeps a model selected from the slash-command autocomplete or a
// persisted-localStorage value renderable with its proper label
// instead of falling back to the bare ID. #1567.
if(Array.isArray(g.extra_models)){
try{ og.dataset.extraModels=JSON.stringify(g.extra_models); }catch(_e){ og.dataset.extraModels='[]'; }
for(const m of g.extra_models){
if(m && m.id) _dynamicModelLabels[m.id]=m.label||m.id;
}
}
sel.appendChild(og);
}
_reconcileModelDropdownSelection(sel,data,previousSelection,opts);
if(typeof syncModelChip==='function') syncModelChip();
const dd=$('composerModelDropdown');
if(dd&&dd.classList.contains('open')&&typeof renderModelDropdown==='function'){
renderModelDropdown();
_positionModelDropdown();
}
// Kick off a background live-model fetch for the active provider.
// This runs after the static list is already shown (no blocking flicker).
if(data.active_provider) _fetchLiveModels(data.active_provider, sel, requestSeq);
}catch(e){
if(requestSeq!==_modelDropdownRequestSeq) return;
// API unavailable -- keep the hardcoded HTML options as fallback
console.warn('Failed to load models from server:',e.message);
if(typeof syncModelChip==='function') syncModelChip();
}
}
// Cache so we don't re-fetch on every page load
const _liveModelCache={};
// Tracks providers for which a live-model fetch is in flight.
// Used by syncTopbar() to defer model corrections until the fetch completes,
// preventing premature fallback to the first static model (#1169).
const _liveModelFetchPending=new Set();
function _addLiveModelsToSelect(provider, models, sel){
if(!provider||!models||!models.length||!sel) return 0;
const currentVal=sel.value;
let providerGroup=null;
for(const og of sel.querySelectorAll('optgroup')){
if(og.dataset.provider&&og.dataset.provider===provider){
providerGroup=og; break;
}
if(og.label&&og.label.toLowerCase().includes(provider.toLowerCase())){
providerGroup=og; break;
}
}
if(!providerGroup){
providerGroup=document.createElement('optgroup');
providerGroup.label=provider.charAt(0).toUpperCase()+provider.slice(1)+' (live)';
providerGroup.dataset.provider=provider;
sel.appendChild(providerGroup);
}else if(!providerGroup.dataset.provider){
providerGroup.dataset.provider=provider;
}
const existingIds=new Set([...sel.options].map(o=>o.value));
// Normalized dedup strips provider/custom prefixes and namespaces (#907, #3478).
const _normId=id=>{
let s=String(id||'');
if(s.startsWith('@')&&s.includes(':')){
if(s.startsWith('@custom:')){
s=s.substring(s.lastIndexOf(':')+1)||s;
}else{
s=s.substring(s.indexOf(':')+1);
}
}
s=s.split('/').pop();
return s.replace(/-/g,'.').toLowerCase();
};
const existingNorm=new Set([...sel.options].map(o=>_normId(o.value)));
let added=0;
const _ap=(window._activeProvider||'').toLowerCase();
const _providerLower=String(provider||'').toLowerCase();
const _isNamedCustomActiveProvider=_ap.startsWith('custom:');
const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && _ap!=='openai-codex' && (_providerLower===_ap||_isNamedCustomActiveProvider&&_providerLower===_ap);
for(const m of models){
let mid=m.id;
if(_isPortalFetch && !mid.startsWith('@')){
mid=`@${provider}:${mid}`;
}
if(existingIds.has(mid)) continue;
if(existingNorm.has(_normId(mid))) continue; // dedup cross-prefix duplicates (#907)
const opt=document.createElement('option');
opt.value=mid;
opt.textContent=m.label||m.id;
opt.title='Live model — fetched from provider';
opt.dataset.provider=provider;
providerGroup.appendChild(opt);
_dynamicModelLabels[mid]=m.label||m.id;
added++;
}
const currentState=(currentVal&&typeof _modelStateForSelect==='function')
? _modelStateForSelect(sel, currentVal)
: {model:currentVal||'', model_provider:(S.session&&S.session.model_provider)||null};
const currentProvider=currentState&¤tState.model_provider||null;
if(added>0 && currentVal) _applyModelToDropdown(currentVal, sel, currentProvider, {forceRefresh:true});
// After live models are added, re-apply the session's model in case it was
// absent from the static list and syncTopbar() fired before the live fetch
// completed (#1169). This ensures the session model wins over any premature
// fallback that may have set sel.value to the first available option.
if(S.session && S.session.model && sel.id==='modelSelect'){
const sessionProvider=S.session.model_provider||null;
const sessionAlreadyRefreshed=added>0 && currentVal
&& String((currentState&¤tState.model)||'')===String(S.session.model||'')
&& String((currentState&¤tState.model_provider)||'')===String(sessionProvider||'');
const reapplied=_applyModelToDropdown(S.session.model, sel, sessionProvider, {forceRefresh:added>0&&!sessionAlreadyRefreshed});
if(reapplied && typeof syncModelChip==='function') syncModelChip();
}
return added;
}
async function _fetchLiveModels(provider, sel, requestSeq=null){
if(!provider||!sel) return;
if(requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq) return;
// Already fetched — apply cached models to this select element (#872)
if(_liveModelCache[provider]){
if(requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq) return;
const added=_addLiveModelsToSelect(provider,_liveModelCache[provider],sel);
if(added>0 && typeof syncModelChip==='function') syncModelChip();
return;
}
_liveModelFetchPending.add(provider);
try{
const url=new URL('api/models/live',document.baseURI||location.href);
url.searchParams.set('provider',provider);
const _liveRes=await fetch(url.href,{credentials:'include'});
if(requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq) return;
if(_redirectIfUnauth(_liveRes)) return;
const data=await _liveRes.json();
if(requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq) return;
if(!data.models||!data.models.length) return;
_liveModelCache[provider]=data.models;
if(requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq) return;
const added=_addLiveModelsToSelect(provider,data.models,sel);
if(added>0){
if(typeof syncModelChip==='function') syncModelChip();
console.debug('[hermes] Live models loaded for',provider+':',added,'new models added');
}
}catch(e){
console.debug('[hermes] Live model fetch failed for',provider,e.message);
}finally{
_liveModelFetchPending.delete(provider);
}
}
/**
* Check if the given model ID belongs to a different provider than the one
* currently configured in Hermes. Returns a warning string if mismatched,
* or null if the selection looks compatible.
*
* Provider detection is intentionally loose — we compare the model's slash
* prefix (e.g. "openai/" from "openai/gpt-4o") against the active provider
* name. Custom/local endpoints report active_provider='custom', a named
* custom provider such as 'custom:zenmux', or the base_url hostname; skip the
* check for those values to avoid false positives.
*/
function _checkProviderMismatch(modelId){
const ap=(window._activeProvider||'').toLowerCase();
if(_providerSkipsModelMismatchWarning(ap)) return null; // can't reliably check
// @provider: prefixed IDs came from that provider's live model list — no mismatch possible
if(modelId.startsWith('@')) return null;
const slash=modelId.indexOf('/');
if(slash<0) return null; // bare model name, no provider prefix
const modelProvider=modelId.substring(0,slash).toLowerCase();
// Normalise common aliases
const aliases={'claude':'anthropic','gpt':'openai','gemini':'google'};
const norm=p=>aliases[p]||p;
if(norm(modelProvider)!==norm(ap)){
return (window.t?window.t('provider_mismatch_warning',modelId,ap):
`"${modelId}" may not work with your configured provider (${ap}). Send anyway or run \`hermes model\` to switch.`);
}
return null;
}
function _selectedModelOption(){
const sel=$('modelSelect');
if(!sel) return null;
return sel.options[sel.selectedIndex]||null;
}
function _normalizeConfiguredModelKey(modelId){
let s=String(modelId||'').trim().toLowerCase();
let strippedAtProvider=false;
// Strip @provider: prefix (e.g., @custom:jingdong:GLM-5 -> jingdong:GLM-5).
// Defensive: trailing-colon / trailing-slash falls back to the original key
// so malformed configs don't collapse distinct ids to '' (matches backend _norm_model_id).
if(s.startsWith('@')&&s.includes(':')){const ci=s.indexOf(':',1);const cand=s.slice(ci+1);strippedAtProvider=!!cand;s=cand||s;}
// Skip slash-based stripping for URI-scheme IDs (e.g. gpt://folder/model)
// whose slashes are path separators, not provider delimiters (#3429).
const _hasScheme=/^[a-z][a-z0-9+.-]*:\/\//i.test(s);
if(!_hasScheme){
// Strip provider-qualified prefixes that contain colons before the first
// slash (e.g. 'custom:llm-proxy/model' → 'model'). Without this, badge-
// key variants like 'custom:llm-proxy/opencode_go/deepseek-v4-pro' and the
// bare 'opencode_go/deepseek-v4-pro' produce different normalized keys and
// aren't deduped in the configured section (#3360).
if(!strippedAtProvider&&s.includes('/')&&s.indexOf(':')!==-1&&s.indexOf(':')String(badge&&badge.provider||'').toLowerCase()===provider);
if(providerMatch) return providerMatch;
return matches.length===1 ? matches[0] : null;
}
return matches[0];
}
function syncModelChip(){
const sel=$('modelSelect');
const chip=$('composerModelChip');
const label=$('composerModelLabel');
const mobileLabel=$('composerMobileModelLabel');
const mobileAction=$('composerMobileModelAction');
const dd=$('composerModelDropdown');
if(!sel||!chip||!label) return;
// Don't show a model label until boot has finished loading to prevent flash of wrong default
if(!S._bootReady){
label.textContent='';
if(mobileLabel) mobileLabel.textContent='';
chip.title='Conversation model';
return;
}
const opt=_selectedModelOption();
const text=opt?opt.textContent:getModelLabel(sel.value||'');
const gatewayRouting=_latestGatewayRoutingForSession(S.session);
const displayText=_formatGatewayModelLabel(sel.value||'',text,gatewayRouting)||text;
label.textContent=displayText;
if(mobileLabel) mobileLabel.textContent=displayText;
chip.title=gatewayRouting?`${sel.value||'Conversation model'} ${_gatewayRoutingLabel(gatewayRouting)}`:(sel.value||'Conversation model');
chip.classList.toggle('active',!!(dd&&dd.classList.contains('open')));
if(mobileAction) mobileAction.classList.toggle('active',!!(dd&&dd.classList.contains('open')));
}
function _positionModelDropdown(){
const dd=$('composerModelDropdown');
const chip=$('composerModelChip');
const mobileAction=$('composerMobileModelAction');
const footer=document.querySelector('.composer-footer');
if(!dd||!footer) return;
const panel=$('composerMobileConfigPanel');
const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:(chip&&chip.offsetParent?chip:mobileAction);
if(!anchor) return;
const chipRect=anchor.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
left=Math.max(0, Math.min(left, maxLeft));
dd.style.left=`${left}px`;
}
function _readModelOverflowData(group){
if(!group||!group.dataset||!group.dataset.extraModels) return [];
try{
const parsed=JSON.parse(group.dataset.extraModels);
return Array.isArray(parsed)?parsed.filter(m=>m&&m.id):[];
}catch(_e){
return [];
}
}
function _appendOverflowOptionsToGroup(group, extraModels){
if(!group||!Array.isArray(extraModels)||!extraModels.length) return 0;
// The selected model may already have been injected into the (e.g. a
// hidden overflow model picked from search via _ensureModelOptionInDropdown).
// Appending it again here would create a duplicate row once the group expands,
// so reuse/move any existing option with the same value instead of re-creating it. (#3691)
const parentSelect=(group.parentNode&&group.parentNode.tagName==='SELECT')?group.parentNode:null;
const existingByValue=new Map();
if(parentSelect){
for(const opt of Array.from(parentSelect.querySelectorAll('option'))){
if(opt&&typeof opt.value==='string') existingByValue.set(opt.value,opt);
}
}
let appended=0;
for(const m of extraModels){
if(!m||!m.id) continue;
const existing=existingByValue.get(m.id);
if(existing){
// Move the already-present option into this group rather than duplicating it.
if(existing.parentNode!==group) group.appendChild(existing);
continue;
}
const opt=document.createElement('option');
opt.value=m.id;
opt.textContent=m.label||m.id;
group.appendChild(opt);
appended++;
}
if(group.dataset){
group.dataset.extraModels='[]';
group.dataset.overflowExpanded='1';
}
return appended;
}
function renderModelDropdown(){
const dd=$('composerModelDropdown');
const sel=$('modelSelect');
if(!dd||!sel) return;
// Group(s) that must render OPEN even though they aren't the selected group —
// set when the user expands a group's overflow via "Show more" so a later full
// re-render doesn't re-collapse it (_groupOpenState is rebuilt per render, so
// this cross-render intent persists on a global). Resolved as a function-local
// so renderModelDropdown stays self-contained when eval'd in isolation (the
// #3691 node test driver evals the function body without module scope).
const _forceOpenGroups=(()=>{
const _g=(typeof window!=='undefined')?window:(typeof globalThis!=='undefined'?globalThis:{});
if(!_g.__modelGroupForceOpen) _g.__modelGroupForceOpen=new Set();
return _g.__modelGroupForceOpen;
})();
const _modelData=[];
const _groupMeta=new Map();
const _groupOrder=[];
const _badgeMap=window._configuredModelBadges||{};
const _ensureGroupMeta=(groupKey,groupLabel,providerId,optgroup)=>{
if(!_groupMeta.has(groupKey)){
_groupMeta.set(groupKey,{
key:groupKey,
label:groupLabel||'',
providerId:providerId||'',
optgroup:optgroup||null,
modelsEndpointError:null,
modelCount:0,
hiddenCount:0,
endpointErrorOnly:false,
});
_groupOrder.push(groupKey);
}
return _groupMeta.get(groupKey);
};
const _vendorPrefix=(rawId)=>{
const stripped=String(rawId||'').replace(/^@([^:]+:)+/,'');
const slash=stripped.indexOf('/');
return slash>0?stripped.slice(0,slash):'';
};
const SUB_GROUP_PROVIDERS=new Set(['openrouter','nous']);
const SUB_GROUP_MIN_MODELS=8;
for(const child of Array.from(sel.children)){
if(child.tagName==='OPTGROUP'){
const providerId=child.dataset&&child.dataset.provider?child.dataset.provider:'';
const groupKey=providerId||child.label||`group-${_groupOrder.length}`;
const groupMeta=_ensureGroupMeta(groupKey,child.label||'',providerId,child);
let modelsEndpointError=null;
if(child.dataset&&child.dataset.modelsEndpointError){
try{ modelsEndpointError=JSON.parse(child.dataset.modelsEndpointError); }catch(_e){ modelsEndpointError=null; }
}
groupMeta.modelsEndpointError=modelsEndpointError;
for(const opt of Array.from(child.children)){
const rawValue=String(opt.value||'');
const displayName=rawValue.startsWith('@custom:')
? getModelLabel(rawValue)
: (opt.textContent||getModelLabel(rawValue));
const entry={value:opt.value,name:esc(displayName),id:esc(opt.value),group:child.label||'',groupKey,providerId,modelsEndpointError,badge:_getConfiguredModelBadge(opt.value,_badgeMap,providerId),hiddenByDefault:false};
_modelData.push(entry);
groupMeta.modelCount++;
}
for(const overflowModel of _readModelOverflowData(child)){
const displayName=overflowModel.id.startsWith('@custom:')
? getModelLabel(overflowModel.id)
: (overflowModel.label||getModelLabel(overflowModel.id));
_modelData.push({
value:overflowModel.id,
name:esc(displayName),
id:esc(overflowModel.id),
group:child.label||'',
groupKey,
providerId,
modelsEndpointError,
badge:_getConfiguredModelBadge(overflowModel.id,_badgeMap,providerId),
hiddenByDefault:true,
});
groupMeta.modelCount++;
groupMeta.hiddenCount++;
}
if(modelsEndpointError && !child.children.length && !groupMeta.hiddenCount){
groupMeta.endpointErrorOnly=true;
_modelData.push({value:`__models_endpoint_error__:${providerId||child.label||''}`,name:'',id:'',group:child.label||'',groupKey,providerId,modelsEndpointError,endpointErrorOnly:true});
}
}
if(child.tagName==='OPTION'){
const groupKey='__ungrouped__';
_ensureGroupMeta(groupKey,'','',null);
const rawValue=String(child.value||'');
const displayName=rawValue.startsWith('@custom:')
? getModelLabel(rawValue)
: (child.textContent||getModelLabel(rawValue));
_modelData.push({value:child.value,name:esc(displayName),id:esc(child.value),group:'',groupKey,providerId:'',badge:_getConfiguredModelBadge(child.value,_badgeMap),hiddenByDefault:false});
_groupMeta.get(groupKey).modelCount++;
}
}
const _existingConfiguredKeys=new Set(_modelData.map(existing=>_normalizeConfiguredModelKey(existing.value)));
for(const [modelId,badge] of Object.entries(_badgeMap)){
if(_existingConfiguredKeys.has(_normalizeConfiguredModelKey(modelId))) continue;
_modelData.push({
value:modelId,
name:esc(getModelLabel(modelId)),
id:esc(modelId),
group:'',
badge,
});
_existingConfiguredKeys.add(_normalizeConfiguredModelKey(modelId));
}
// Create search input FIRST before filterModels definition
const _scopeNote=document.createElement('div');
_scopeNote.className='model-scope-note';
_scopeNote.textContent=t('model_scope_advisory')||'Applies to this conversation from your next message.';
const _searchRow=document.createElement('div');
_searchRow.className='model-search-row';
_searchRow.innerHTML=`${li('x',10)} `;
const _si=_searchRow.querySelector('.model-search-input');
const _sc=_searchRow.querySelector('.model-search-clear');
// Create custom model section elements
const _custSep=document.createElement('div');
_custSep.className='model-group model-custom-sep';
_custSep.textContent=t('model_custom_label')||'Custom model ID';
const _custRow=document.createElement('div');
_custRow.className='model-custom-row';
_custRow.innerHTML=`${li('plus',12)} `;
const _ci=_custRow.querySelector('.model-custom-input');
const _cb=_custRow.querySelector('.model-custom-btn');
const _configuredRank=(badge)=>{
if(!badge) return Number.POSITIVE_INFINITY;
if(badge.role==='primary') return 0;
if(badge.role==='fallback'){
const m=String(badge.label||'').match(/fallback\s+(\d+)/i);
return m?Number(m[1]):999;
}
return 500;
};
const _renderProviderEndpointHint=(entry,parent)=>{
if(!entry||!entry.label||!entry.modelsEndpointError) return;
const hint=document.createElement('div');
hint.className='model-provider-hint';
hint.textContent=entry.modelsEndpointError.message||'Models endpoint could not be reached for this provider.';
(parent||dd).appendChild(hint);
};
// Build a single model-option row (mirrors the main render loop's row markup),
// used both by the main render and by the in-place overflow reveal below.
const _buildModelRow=(m,sel,withProviderChip)=>{
const row=document.createElement('div');
row.className='model-opt'+(m.value===sel.value?' active':'');
const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')} `:'';
const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):'';
const providerChip=(_plainGroup&&withProviderChip)?`${esc(_plainGroup)} `:'';
row.innerHTML=`${esc(m.name)} ${badgeHtml}${providerChip}
${esc(m.id)} `;
row.onclick=()=>selectModelFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null);
return row;
};
const _expandOverflowGroup=(groupMetaEntry)=>{
if(!groupMetaEntry||!groupMetaEntry.optgroup) return;
const og=groupMetaEntry.optgroup;
const groupKey=groupMetaEntry.key;
const extraModels=_readModelOverflowData(og);
// Nothing to reveal — no overflow tail advertised.
if(!extraModels.length) return;
// Append the overflow models to the source so the dropdown's state
// stays the source of truth (search, re-render, selection all see them).
// NOTE: guard on extraModels.length (above), NOT on the append return value —
// _appendOverflowOptionsToGroup returns the count of NEWLY-created s
// and returns 0 (while still clearing dataset.extraModels) when every overflow
// model already existed as an option. Bailing on a 0 return would leave those
// already-present-but-hidden rows unrevealed and the expander dead (#bug3).
_appendOverflowOptionsToGroup(og,extraModels);
// Full re-render fallback — the proven path. Used when the in-place reveal
// can't run (minimal/headless DOM without CSS.escape/rAF/insertBefore, or any
// unexpected failure). Produces the same end state: overflow appended,
// expander gone, search term reapplied.
const _fullReRender=()=>{
const _term=(_si&&_si.value)||'';
renderModelDropdown();
const ns=dd.querySelector('.model-search-input');
if(ns){ ns.value=_term; (ns._listeners&&ns._listeners.input)?ns._listeners.input():ns.dispatchEvent(new Event('input')); }
};
// IN-PLACE reveal: build the newly-revealed rows and insert them directly into
// the existing group wrapper (before the "Show more" expander), then remove
// the expander. No full re-render — so the group stays open, every other
// group keeps its collapsed/open state, and the scroll position is preserved.
// The user lands on the first new row. Falls back to a full re-render if the
// runtime lacks the DOM APIs this needs.
const _canInPlace = typeof CSS!=='undefined' && CSS && typeof CSS.escape==='function'
&& typeof dd.querySelector==='function';
if(!_canInPlace){ _fullReRender(); return; }
let wrap, moreEl;
try{
wrap=dd.querySelector(`.model-group-body[data-group="${CSS.escape(groupKey)}"]`);
moreEl=wrap?wrap.querySelector('.model-opt-more'):null;
}catch(_){ _fullReRender(); return; }
if(!wrap||!moreEl||typeof wrap.insertBefore!=='function'){
_fullReRender();
return;
}
try{
const _plainLabel=String(groupMetaEntry.label||'').replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,'');
const _alreadyShown=new Set(Array.from(wrap.querySelectorAll('.model-opt .model-opt-id')).map(el=>el.textContent));
let firstNewRow=null;
for(const m of extraModels){
if(!m||!m.id) continue;
if(_alreadyShown.has(esc(m.id))) continue;
const row=_buildModelRow({value:m.id,name:m.label||m.id,id:m.id,group:_plainLabel,groupKey,providerId:(og.dataset&&og.dataset.provider)||''},$('modelSelect'),false);
wrap.insertBefore(row,moreEl);
if(!firstNewRow) firstNewRow=row;
}
// Sync the in-memory model data so a later _filterModels() re-render (e.g.
// after a search is typed and cleared) keeps the group fully expanded
// instead of snapping back to the capped view + a fresh "Show more". The
// overflow rows were just appended to the live , so flip their
// _modelData entries to no-longer-hidden and zero the group's hidden count.
for(const _md of _modelData){
if(_md && _md.groupKey===groupKey && _md.hiddenByDefault){
_md.hiddenByDefault=false;
}
}
if(groupMetaEntry && typeof groupMetaEntry.hiddenCount==='number'){
groupMetaEntry.hiddenCount=0;
}
// The group is now fully expanded — drop the "Show more" expander, and bump
// the heading count to the full total. Also force the group OPEN (the user
// just asked to see more of it) regardless of any prior collapsed state.
moreEl.remove();
wrap.style.display='';
_forceOpenGroups.add(groupKey);
const heading=wrap.previousElementSibling;
if(heading&&heading.classList&&heading.classList.contains('model-group')){
const _total=wrap.querySelectorAll('.model-opt').length;
heading.textContent=_total>1?`${_plainLabel} (${_total})`:_plainLabel;
heading.classList.add('collapsible','open');
}
// Scroll so the first newly-revealed row sits near the top of the dropdown
// viewport — the user asked to "land on the new models" after Show more,
// not be reset to the top of the list and not have it jump unpredictably.
if(firstNewRow && typeof firstNewRow.offsetTop==='number' && typeof requestAnimationFrame==='function'){
const _targetTop=Math.max(0,firstNewRow.offsetTop-48);
const _doScroll=()=>{ try{ dd.scrollTop=_targetTop; }catch(_){} };
_doScroll(); // immediate
requestAnimationFrame(()=>{ _doScroll(); requestAnimationFrame(_doScroll); });
if(typeof setTimeout==='function') setTimeout(_doScroll,80); // after any refocus settles
}
}catch(_err){
// Any unexpected DOM failure — fall back to the proven full re-render so
// the overflow still gets revealed.
_fullReRender();
}
};
// Collapsible group state — persists across _filterModels calls
const _groupOpenState={};
let _prevHasSearch=false; // tracks search->empty transition to reset open-state
let _groupWrappers={};
// The group that owns the currently-selected model. Groups start COLLAPSED by
// default (#4279); the selected provider's group is the one exception so the
// user always sees their active model without expanding anything. (#4279 + UX)
const _selectedGroupKey=(()=>{
const _selVal=String((sel&&sel.value)||'');
if(!_selVal) return null;
const _hit=_modelData.find(m=>m&&!m.endpointErrorOnly&&String(m.value||'')===_selVal);
return _hit?_hit.groupKey:null;
})();
const _makeModelRow=(m,sel,shouldRenderHeading)=>{
const row=document.createElement('div');
row.className='model-opt'+(m.value===sel.value?' active':'');
const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')} `:'';
const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):'';
const _underOwnHeading=shouldRenderHeading&&!!(m.groupKey&&_groupWrappers[m.groupKey]);
const providerChip=(_plainGroup&&!_underOwnHeading)?`${esc(_plainGroup)} `:'';
row.innerHTML=`${esc(m.name)} ${badgeHtml}${providerChip}
${esc(m.id)} `;
row.onclick=()=>selectModelFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null);
return row;
};
const _filterModels=(term)=>{
term=term.trim().toLowerCase();
const hasSearch=!!term;
// On a fresh search, expand all groups so every match is visible (#collapse).
if(hasSearch) for(const k in _groupOpenState) _groupOpenState[k]=true;
// When a search is CLEARED (search -> empty), reset the per-group open state
// so the collapsed-except-selected default re-applies — otherwise every group
// the search auto-expanded would stay open, defeating the collapse UX. Groups
// the user explicitly expanded via "Show more" (_forceOpenGroups) and the
// selected group remain open through the defaulting logic below.
else if(_prevHasSearch){ for(const k in _groupOpenState) delete _groupOpenState[k]; }
_prevHasSearch=hasSearch;
const found=new Set();
for(const m of _modelData){
const name=m.name.toLowerCase();
const id=m.id.toLowerCase();
if(name.includes(term)||id.includes(term)){
found.add(m.value);
}
}
const matches=(m)=>!term||found.has(m.value);
const configuredCandidates=_modelData
.filter(m=>m.badge&&matches(m));
const configuredBySemanticKey=new Map();
const _configuredProviderKey=(m)=>String((m&&m.badge&&m.badge.provider)||_providerFromModelValue(m&&m.value)||'').toLowerCase();
const _configuredModelKey=(m)=>_normalizeConfiguredModelKey(m&&m.value||'');
const _configuredDisplayPriority=(m)=>{
// Prefer plain IDs over provider-qualified aliases for readability.
const v=String((m&&m.value)||'');
if(v.startsWith('@')) return 0;
if(v.includes('/')) return 1;
return 2;
};
for(const candidate of configuredCandidates){
const semanticKey=`${_configuredProviderKey(candidate)}::${_configuredModelKey(candidate)}`;
const existing=configuredBySemanticKey.get(semanticKey);
if(!existing){
configuredBySemanticKey.set(semanticKey,candidate);
continue;
}
const candidatePriority=_configuredDisplayPriority(candidate);
const existingPriority=_configuredDisplayPriority(existing);
if(candidatePriority>existingPriority){
configuredBySemanticKey.set(semanticKey,candidate);
}
}
const configuredModels=[...configuredBySemanticKey.values()]
.sort((a,b)=>{
const configuredRankA=_configuredRank(a.badge);
const configuredRankB=_configuredRank(b.badge);
if(configuredRankA!==configuredRankB) return configuredRankA-configuredRankB;
return a.name.localeCompare(b.name);
});
const configuredIds=new Set(configuredModels.map(m=>m.value));
const configuredSemanticKeys=new Set(configuredModels.map(m=>`${_configuredProviderKey(m)}::${_configuredModelKey(m)}`));
const _effectiveHiddenCount=(groupKey)=>_modelData.filter(m=>
m.groupKey===groupKey
&& m.hiddenByDefault
&& !configuredSemanticKeys.has(`${_configuredProviderKey(m)}::${_configuredModelKey(m)}`)
).length;
dd.innerHTML='';
dd.appendChild(_scopeNote);
dd.appendChild(_searchRow);
dd.appendChild(_custSep);
dd.appendChild(_custRow);
if(configuredModels.length){
const configuredHeading=document.createElement('div');
configuredHeading.className='model-group';
configuredHeading.textContent=t('model_group_configured')||'Configured';
dd.appendChild(configuredHeading);
// 为了显示原始ID,建立 badgeKeyMap: badge对象->原始key
const badgeKeyMap = new Map();
for(const [k, v] of Object.entries(_badgeMap)){
badgeKeyMap.set(v, k);
}
for(const m of configuredModels){
const row=document.createElement('div');
row.className='model-opt'+(m.value===sel.value?' active':'');
let badgeLabel = '';
let modelName = m.name;
if (m.badge) {
// 直接用badge的原始key(即config.yaml里的ID)
const rawId = badgeKeyMap.get(m.badge) || m.value || m.badge.label || 'Configured';
badgeLabel = rawId;
modelName = rawId; // model-opt-name直接用原始ID
if(m.badge.provider){
const providerName=m.badge.provider.replace(/^custom:/,'').split('/')[0];
badgeLabel += ` (${providerName})`;
}
}
const badgeHtml=m.badge?`${esc(badgeLabel)} `:'';
row.innerHTML=`${esc(modelName)} ${badgeHtml}
${esc(m.id)} `;
row.onclick=()=>selectModelFromDropdown(m.value,(m.badge&&m.badge.provider)||m.providerId||null);
dd.appendChild(row);
}
}
for(const groupKey of _groupOrder){
const meta=_groupMeta.get(groupKey);
if(!meta) continue;
const hiddenCount=_effectiveHiddenCount(groupKey);
const groupRows=_modelData.filter(m=>
m.groupKey===groupKey
&& !configuredIds.has(m.value)
&& !m.endpointErrorOnly
&& matches(m)
&& (!m.hiddenByDefault || !!term)
);
const shouldRenderHeading=!!meta.label&&(groupRows.length||meta.endpointErrorOnly||(!term&&hiddenCount));
if(shouldRenderHeading){
const heading=document.createElement('div');
heading.className='model-group';
// When COLLAPSED (hiddenCount>0) keep the backend-decorated label verbatim
// ("Nous (2 of 4)") so the overflow count shows. When EXPANDED, strip that
// decoration and append the rendered-row count, otherwise the heading reads
// "Nous (2 of 4) (4)" (double count). Count rendered rows, not modelCount,
// so hoisted-configured models aren't double-counted. (#3691)
const count=hiddenCount?0:groupRows.length;
const _plainLabel=String(meta.label||'').replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,'');
heading.textContent=count>1?`${_plainLabel} (${count})`:meta.label;
dd.appendChild(heading);
const wrapper=document.createElement('div');
wrapper.className='model-group-body';
wrapper.dataset.group=groupKey;
// A group carrying a provider endpoint-error hint must stay visible by
// default — otherwise the "models endpoint unreachable" warning is hidden
// inside a collapsed body and the user never sees it. (#2540 surface)
const _hasEndpointError=!!(meta&&(meta.modelsEndpointError||meta.endpointErrorOnly));
if(hasSearch) _groupOpenState[groupKey]=true;
else if(_forceOpenGroups.has(groupKey)) _groupOpenState[groupKey]=true;
else if(_hasEndpointError) _groupOpenState[groupKey]=true;
else if(!(groupKey in _groupOpenState)) _groupOpenState[groupKey]=(groupKey===_selectedGroupKey);
if(!_groupOpenState[groupKey]) wrapper.style.display='none';
else heading.classList.add('open');
heading.classList.add('collapsible');
dd.appendChild(wrapper);
_groupWrappers[groupKey]=wrapper;
// Render the provider endpoint-error hint inside the collapsible group
// so it collapses/expands with it (the group is force-opened above when
// an error is present, so the hint stays visible by default).
_renderProviderEndpointHint(meta,wrapper);
heading.addEventListener('click',(e)=>{
e.stopPropagation();
const w=dd.querySelector(`.model-group-body[data-group="${CSS.escape(groupKey)}"]`);
if(!w) return;
const closed=w.style.display==='none';
w.style.display=closed?'':'none';
_groupOpenState[groupKey]=closed;
// Keep the cross-render force-open intent in sync with manual toggles:
// collapsing a previously overflow-expanded group should let it
// re-collapse on the next render too.
if(closed) _forceOpenGroups.add(groupKey); else _forceOpenGroups.delete(groupKey);
heading.classList.toggle('open',closed);
});
const useSubGroups=(
SUB_GROUP_PROVIDERS.has(meta.providerId) &&
groupRows.length>=SUB_GROUP_MIN_MODELS
);
if(useSubGroups){
const byPrefix=new Map();
for(const m of groupRows){
const pfx=_vendorPrefix(m.value)||'other';
if(!byPrefix.has(pfx)) byPrefix.set(pfx,[]);
byPrefix.get(pfx).push(m);
}
const sorted=[...byPrefix.entries()].sort((a,b)=>{
if(a[0]==='other') return 1;
if(b[0]==='other') return -1;
return b[1].length-a[1].length;
});
for(const [pfx,pfxRows] of sorted){
if(pfxRows.length>=2){
const subKey=`${groupKey}::${pfx}`;
if(!(subKey in _groupOpenState)) _groupOpenState[subKey]=true;
if(hasSearch) _groupOpenState[subKey]=true;
const subHeading=document.createElement('div');
subHeading.className='model-group sub collapsible';
subHeading.dataset.group=subKey;
if(_groupOpenState[subKey]) subHeading.classList.add('open');
subHeading.textContent=pfx;
const subWrapper=document.createElement('div');
subWrapper.className='model-group-body sub';
subWrapper.dataset.group=subKey;
if(!_groupOpenState[subKey]) subWrapper.style.display='none';
subHeading.addEventListener('click',(e)=>{
e.stopPropagation();
const closed=subWrapper.style.display==='none';
subWrapper.style.display=closed?'':'none';
_groupOpenState[subKey]=closed;
subHeading.classList.toggle('open',closed);
});
wrapper.appendChild(subHeading);
wrapper.appendChild(subWrapper);
for(const m of pfxRows) subWrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading));
} else {
for(const m of pfxRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading));
}
}
} else {
for(const m of groupRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading));
}
} else {
for(const m of groupRows) dd.appendChild(_makeModelRow(m,sel,shouldRenderHeading));
}
if(!term&&hiddenCount){
const showAll=document.createElement('div');
showAll.className='model-opt-more';
showAll.tabIndex=0;
showAll.setAttribute('role','button');
const _moreLabel=esc(t('model_show_all_models',hiddenCount)||`Show ${hiddenCount} more`);
showAll.innerHTML=`${_moreLabel} `;
const _doExpand=()=>{
// The reveal itself (in-place row insert + open + scroll-to-new) is
// handled by _expandOverflowGroup; just trigger it.
_expandOverflowGroup(meta);
};
showAll.onclick=(e)=>{
if(e&&typeof e.stopPropagation==='function') e.stopPropagation();
_doExpand();
};
showAll.addEventListener('keydown',e=>{
if(e.key==='Enter'||e.key===' '){
e.preventDefault();
_doExpand();
}
});
// Keep the expander inside the collapsible group so it hides/shows with it.
if(_groupWrappers[groupKey]) _groupWrappers[groupKey].appendChild(showAll);
else dd.appendChild(showAll);
}
}
if(term&&found.size===0){
const noResult=document.createElement('div');
noResult.className='model-search-no-results';
noResult.textContent=t('model_search_no_results')||'No models found';
noResult.style.padding='12px 14px';
noResult.style.color='var(--muted)';
noResult.style.textAlign='center';
dd.appendChild(noResult);
}
_si.focus();
};
_si.addEventListener('input',()=>_filterModels(_si.value));
// Keyboard navigation through filtered model rows (#2791).
const _visibleModelRows=()=>Array.from(dd.querySelectorAll('.model-opt,.model-opt-more')).filter(el=>{
let node=el.parentElement;
while(node&&node!==dd){
if(node.classList.contains('model-group-body')&&node.style.display==='none') return false;
node=node.parentElement;
}
return true;
});
const _activeRowIndex=(rows)=>rows.findIndex(r=>r.classList.contains('is-highlighted'));
const _highlightRow=(rows,idx)=>{
for(const r of rows) r.classList.remove('is-highlighted');
if(idx<0||idx>=rows.length) return;
const row=rows[idx];
row.classList.add('is-highlighted');
if(typeof row.scrollIntoView==='function') row.scrollIntoView({block:'nearest'});
};
_si.addEventListener('keydown',e=>{
if(e.key==='Escape'){closeModelDropdown();return;}
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){
const rows=_visibleModelRows();
if(!rows.length){if(e.key==='Enter') e.preventDefault();return;}
const cur=_activeRowIndex(rows);
if(e.key==='ArrowDown'){e.preventDefault();_highlightRow(rows,cur<0?0:Math.min(rows.length-1,cur+1));return;}
if(e.key==='ArrowUp'){e.preventDefault();_highlightRow(rows,cur<=0?rows.length-1:cur-1);return;}
if(e.key==='Enter'){
e.preventDefault();
const pick=cur>=0?rows[cur]:rows[0];
if(pick) pick.click();
}
}
});
_si.addEventListener('click',e=>e.stopPropagation());
_sc.onclick=()=>{ _si.value=''; _filterModels(''); _si.focus(); };
_sc.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){ _si.value=''; _filterModels(''); _si.focus(); e.preventDefault(); }});
const _applyCustom=()=>{const v=_ci.value.trim();if(!v)return;selectModelFromDropdown(v);_ci.value='';};
_cb.onclick=_applyCustom;
_ci.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();_applyCustom();}if(e.key==='Escape'){closeModelDropdown();}});
_ci.addEventListener('click',e=>e.stopPropagation());
dd.appendChild(_scopeNote);
dd.appendChild(_searchRow);
dd.appendChild(_custSep);
dd.appendChild(_custRow);
_filterModels('');
}
async function selectModelFromDropdown(value){
const preferredProviderId=arguments[1];
const sel=$('modelSelect');
if(!sel) { closeModelDropdown(); return; }
const provider=String(preferredProviderId||'').trim()||null;
const currentState=(typeof _modelStateForSelect==='function')
? _modelStateForSelect(sel, sel.value)
: {model:sel.value,model_provider:null};
const sameModel=String(currentState.model||'')===String(value||'');
const sameProvider=String(currentState.model_provider||'')===String(provider||'');
if(sameModel&&sameProvider){ closeModelDropdown(); return; }
// Resolve the provider-specific option so duplicate bare IDs (e.g. gpt-5.5
// under OpenAI Codex vs OpenRouter) update session model_provider correctly.
if(typeof _ensureModelOptionInDropdown==='function'){
_ensureModelOptionInDropdown(value, sel, provider);
}else{
sel.value=value;
}
syncModelChip();
closeModelDropdown();
if(typeof sel.onchange==='function') await sel.onchange();
}
async function toggleModelDropdown(){
const dd=$('composerModelDropdown');
const chip=$('composerModelChip');
const sel=$('modelSelect');
if(!dd||!chip||!sel) return;
const open=dd.classList.contains('open');
if(open){closeModelDropdown(); return;}
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
if(typeof closeReasoningDropdown==='function') closeReasoningDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
if(typeof window._ensureModelDropdownReady==='function'){
const ready=window._ensureModelDropdownReady();
if(ready&&typeof ready.catch==='function') ready.catch(()=>{});
}
if(dd.classList.contains('open')) return;
renderModelDropdown();
dd.classList.add('open');
_positionModelDropdown();
chip.classList.add('active');
const mobileAction=$('composerMobileModelAction');
if(mobileAction) mobileAction.classList.add('active');
}
function closeModelDropdown(){
const dd=$('composerModelDropdown');
const chip=$('composerModelChip');
const mobileAction=$('composerMobileModelAction');
if(dd) dd.classList.remove('open');
if(chip) chip.classList.remove('active');
if(mobileAction) mobileAction.classList.remove('active');
}
document.addEventListener('click',e=>{
if(
!e.target.closest('#composerModelChip') &&
!e.target.closest('#composerMobileModelAction') &&
!e.target.closest('#composerModelDropdown')
) closeModelDropdown();
});
window.addEventListener('resize',()=>{
const dd=$('composerModelDropdown');
if(dd&&dd.classList.contains('open')) _positionModelDropdown();
// Keep the reasoning dropdown aligned under its chip when the window
// resizes while open — same pattern as the model dropdown above.
const rdd=$('composerReasoningDropdown');
if(rdd&&rdd.classList.contains('open')&&typeof _positionReasoningDropdown==='function'){
_positionReasoningDropdown();
}
});
// ── Reasoning effort chip ────────────────────────────────────────────────────
let _currentReasoningEffort=null;
let _currentReasoningEffortsSupported=null;
function _normalizeReasoningEffort(eff){
return String(eff||'').trim().toLowerCase();
}
function _formatReasoningEffortLabel(effort){
if(effort==='none') return 'None';
if(!effort) return 'Default';
return effort;
}
function _reasoningEffortContext(){
const sel=$('modelSelect');
const model=(S&&S.session&&S.session.model)||(sel&&sel.value)||'';
let provider=(S&&S.session&&S.session.model_provider)||'';
if(!provider&&sel&&model&&typeof _modelStateForSelect==='function'){
provider=_modelStateForSelect(sel, model).model_provider||'';
}
const ctx={};
if(model) ctx.model=model;
if(provider) ctx.provider=provider;
return ctx;
}
function _reasoningEffortQuery(){
const params=new URLSearchParams(_reasoningEffortContext());
const qs=params.toString();
return qs?('?'+qs):'';
}
function _applyReasoningOptions(supportedEfforts){
const dd=$('composerReasoningDropdown');
if(!dd) return;
const supported=new Set(Array.isArray(supportedEfforts)?supportedEfforts:[]);
dd.querySelectorAll('.reasoning-option').forEach(function(opt){
const effort=opt.dataset.effort;
if(effort==='none'){
opt.style.display='';
return;
}
if(!supported.size){
opt.style.display='none';
return;
}
opt.style.display=supported.has(effort)?'':'none';
});
}
function _applyReasoningChip(eff){
const meta=arguments[1]||null;
const effort=_normalizeReasoningEffort(eff);
_currentReasoningEffort=effort;
if(meta&&Array.isArray(meta.supported_efforts)){
_currentReasoningEffortsSupported=meta.supported_efforts;
}
const wrap=$('composerReasoningWrap');
const label=$('composerReasoningLabel');
const chip=$('composerReasoningChip');
const mobileLabel=$('composerMobileReasoningLabel');
const mobileAction=$('composerMobileReasoningAction');
if(!wrap||!label) return;
const supportedEfforts=(typeof _currentReasoningEffortsSupported==='undefined')
?null
:_currentReasoningEffortsSupported;
const supports=Array.isArray(supportedEfforts)
?supportedEfforts.length>0
:true;
if(!supports){
wrap.style.display='none';
if(mobileAction) mobileAction.style.display='none';
return;
}
wrap.style.display='';
if(mobileAction) mobileAction.style.display='';
if(typeof _applyReasoningOptions==='function') _applyReasoningOptions(supportedEfforts);
const text=_formatReasoningEffortLabel(effort);
label.textContent=text;
if(mobileLabel) mobileLabel.textContent=text;
if(chip){
const inactive=!effort||effort==='none';
chip.classList.toggle('inactive',inactive);
chip.title='Reasoning effort: '+text;
}
if(mobileAction) mobileAction.classList.toggle('inactive',!effort||effort==='none');
_highlightReasoningOption(effort);
}
// Tracks the model/provider identity of the last reasoning fetch so routine
// topbar syncs can serve the cached chip state instead of re-hitting the
// network. null = never fetched.
let _lastReasoningFetchKey=null;
// Monotonic dispatch counter. Each fetchReasoningChip() increments it and the
// async handlers capture their own value; a response (success OR failure) only
// applies if it is still the most recent dispatch. This defeats out-of-order
// resolution even when two fetches share the same model/provider key (e.g. a
// profile switch that resets the cache and refetches the same default model but
// a different agent.reasoning_effort) — #4650 review.
let _reasoningFetchSeq=0;
function fetchReasoningChip(){
// Set the cache key OPTIMISTICALLY before the request so rapid routine syncs
// while this GET is in flight short-circuit instead of re-dispatching (that
// in-flight window is exactly where the #4650 storm lived).
const key=_reasoningEffortQuery();
const seq=++_reasoningFetchSeq;
_lastReasoningFetchKey=key;
api('/api/reasoning'+key).then(function(st){
// Ignore a stale/superseded response: only the most recent dispatch may
// apply, so an older in-flight GET can't poison the current chip (#4650).
if(seq!==_reasoningFetchSeq) return;
_applyReasoningChip((st&&st.reasoning_effort)||'', st||{});
}).catch(function(){
// Same staleness guard on failure: a stale error must neither hide the chip
// nor clear a newer fetch's key. Only the latest dispatch clears the key so
// routine syncs retry after a genuine transient failure.
if(seq!==_reasoningFetchSeq) return;
_lastReasoningFetchKey=null;
_applyReasoningChip('', {supported_efforts:[]});
});
}
function syncReasoningChip(){
// #4650: syncTopbar() calls this on every routine UI refresh, and during
// streaming those fire at high frequency. Before a9ce2889 this served the
// cached _currentReasoningEffort after the first load; that commit made it
// refetch unconditionally to refresh supported-efforts after a model switch,
// which turned ordinary syncs into a GET /api/reasoning storm (one per token).
// Restore the cache short-circuit but keep a9ce2889's intent: only hit the
// network when nothing is cached yet OR the model/provider identity changed
// since the last fetch (the only inputs that change /api/reasoning's answer).
// The user-pick and model-switch paths still update the cache directly.
const key=_reasoningEffortQuery();
// Short-circuit on the KEY alone: if a fetch for this exact model/provider has
// already been dispatched (in-flight) or completed, do not dispatch another —
// this is what stops the #4650 storm, including the COLD-cache window where
// _currentReasoningEffort is still null between the first dispatch and its
// response (10 syncs before the first GET resolves must produce ONE request,
// not ten). Apply the cached chip only once we actually have an effort value.
if(_lastReasoningFetchKey===key){
if(_currentReasoningEffort!==null) _applyReasoningChip(_currentReasoningEffort);
return;
}
fetchReasoningChip();
}
function _highlightReasoningOption(effort){
const dd=$('composerReasoningDropdown');
if(!dd) return;
dd.querySelectorAll('.reasoning-option').forEach(function(opt){
opt.classList.toggle('selected',opt.dataset.effort===effort);
});
}
function toggleReasoningDropdown(){
const dd=$('composerReasoningDropdown');
const chip=$('composerReasoningChip');
if(!dd||!chip) return;
const open=dd.classList.contains('open');
if(open){closeReasoningDropdown();return;}
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
closeModelDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
_highlightReasoningOption(_currentReasoningEffort);
dd.classList.add('open');
_positionReasoningDropdown();
chip.classList.add('active');
const mobileAction=$('composerMobileReasoningAction');
if(mobileAction) mobileAction.classList.add('active');
}
function _positionReasoningDropdown(){
const dd=$('composerReasoningDropdown');
const chip=$('composerReasoningChip');
const mobileAction=$('composerMobileReasoningAction');
const footer=document.querySelector('.composer-footer');
if(!dd||!chip||!footer) return;
const panel=$('composerMobileConfigPanel');
const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:chip;
const chipRect=anchor.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
const maxLeft=Math.max(0,footer.clientWidth-dd.offsetWidth);
left=Math.max(0,Math.min(left,maxLeft));
dd.style.left=`${left}px`;
}
function closeReasoningDropdown(){
const dd=$('composerReasoningDropdown');
const chip=$('composerReasoningChip');
const mobileAction=$('composerMobileReasoningAction');
if(dd) dd.classList.remove('open');
if(chip) chip.classList.remove('active');
if(mobileAction) mobileAction.classList.remove('active');
}
document.addEventListener('click',function(e){
if(
!e.target.closest('#composerReasoningChip') &&
!e.target.closest('#composerMobileReasoningAction') &&
!e.target.closest('#composerReasoningDropdown')
) closeReasoningDropdown();
if(e.target.closest('.reasoning-option')){
const opt=e.target.closest('.reasoning-option');
const effort=opt&&opt.dataset.effort;
if(effort){
const payload=Object.assign({effort:effort},_reasoningEffortContext());
api('/api/reasoning',{method:'POST',body:JSON.stringify(payload)})
.then(function(st){
_applyReasoningChip((st&&st.reasoning_effort)||effort, st||{});
showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort));
})
.catch(function(){showToast('🧠 Failed to set effort');});
closeReasoningDropdown();
}
}
});
// ── Session toolsets chip (#493) ───────────────────────────────────────────
let _currentSessionToolsets = null; // null = active profile defaults, array = custom list
let _toolsetsCatalog = null;
function _applyToolsetsChip(toolsets) {
_currentSessionToolsets = toolsets;
const wrap = $('composerToolsetsWrap');
const label = $('composerToolsetsLabel');
const chip = $('composerToolsetsChip');
if (!wrap || !label) return;
// Visibility is controlled entirely by responsive CSS — the chip shows only
// at wide composer-footer widths (>= 1100px container query). At narrower
// widths the layout is too cramped (model + reasoning + profile + workspace
// + context-ring + send) to add another chip. Cleared inline style so the
// CSS @container query is the single source of truth. State is still
// tracked so /api/session/toolsets continues to work for cron/scripted
// callers regardless of UI visibility. (#1431)
wrap.style.display = '';
const hasCustom = Array.isArray(toolsets) && toolsets.length > 0;
const isStaged = hasCustom
&& typeof S !== 'undefined'
&& S
&& !S.session
&& Array.isArray(S._pendingSessionToolsets);
if (hasCustom) {
const stagedSuffix = isStaged ? ' (staged)' : '';
label.textContent = toolsets.join(', ') + stagedSuffix;
chip.classList.add('has-custom');
chip.title = t('session_toolsets') + ': ' + toolsets.join(', ') + stagedSuffix;
} else {
label.textContent = t('session_toolsets_profile_defaults');
chip.classList.remove('has-custom');
chip.title = t('session_toolsets') + ': ' + t('session_toolsets_profile_defaults');
}
}
function _syncToolsetsChip() {
if (typeof S === 'undefined' || !S || !S.session) {
const stagedToolsets = (typeof S !== 'undefined' && S && Array.isArray(S._pendingSessionToolsets))
? S._pendingSessionToolsets
: null;
_applyToolsetsChip(stagedToolsets);
return;
}
_applyToolsetsChip(S.session.enabled_toolsets || null);
}
function syncToolsetsChip() {
_syncToolsetsChip();
}
function _normalizeToolsetsCatalog(payload) {
const servers = payload && Array.isArray(payload.servers) ? payload.servers : [];
const seen = new Set();
const names = [];
servers.forEach(function(server) {
const name = String((server && server.name) || '').trim();
if (!name || seen.has(name)) return;
seen.add(name);
names.push(name);
});
return names;
}
function _loadToolsetsCatalog() {
if (Array.isArray(_toolsetsCatalog)) return Promise.resolve(_toolsetsCatalog);
return api('/api/mcp/servers')
.then(function(payload) {
_toolsetsCatalog = _normalizeToolsetsCatalog(payload);
return _toolsetsCatalog;
})
.catch(function() {
_toolsetsCatalog = false;
return [];
});
}
function invalidateToolsetsCatalog(payload) {
_toolsetsCatalog = payload && Array.isArray(payload.servers) ? _normalizeToolsetsCatalog(payload) : null;
}
if (typeof window !== 'undefined') window.invalidateToolsetsCatalog = invalidateToolsetsCatalog;
function _toolsetsInputList(input) {
if (!input) return [];
return input.value.split(',').map(s => s.trim()).filter(Boolean);
}
function _ensureToolsetsPresetSection() {
const dd = $('composerToolsetsDropdown');
if (!dd) return null;
let section = $('toolsetsPresetSections');
if (section) return section;
section = document.createElement('div');
section.id = 'toolsetsPresetSections';
section.className = 'toolsets-preset-sections';
const inputRow = dd.querySelector('.toolsets-dropdown-input-row');
if (inputRow) dd.insertBefore(section, inputRow);
else dd.appendChild(section);
return section;
}
function _appendToolsetsLabel(section, text) {
const label = document.createElement('div');
label.className = 'toolsets-dropdown-desc';
label.textContent = text;
section.appendChild(label);
}
function _renderToolsetsPresetSections(opts) {
const state = opts && opts.state;
const input = opts && opts.input;
const section = _ensureToolsetsPresetSection();
if (!section || !state || !input) return;
const selected = _toolsetsInputList(input);
const selectedSet = new Set(selected);
const hasCustom = selected.length > 0;
state.textContent = hasCustom
? '🔧 ' + selected.join(', ')
: '👤 ' + t('session_toolsets_profile_defaults');
section.innerHTML = '';
const defaultsBtn = document.createElement('button');
defaultsBtn.type = 'button';
defaultsBtn.id = 'toolsetsProfileDefaultsBtn';
defaultsBtn.className = 'toolsets-action-btn toolsets-clear-btn';
defaultsBtn.textContent = t('session_toolsets_use_profile_defaults');
section.appendChild(defaultsBtn);
_appendToolsetsLabel(section, t('session_toolsets_configured_servers'));
if (_toolsetsCatalog === null) {
_appendToolsetsLabel(section, t('session_toolsets_loading_servers'));
return;
}
if (_toolsetsCatalog === false) {
_appendToolsetsLabel(section, t('mcp_load_failed'));
return;
}
if (!Array.isArray(_toolsetsCatalog) || !_toolsetsCatalog.length) {
_appendToolsetsLabel(section, t('session_toolsets_no_configured_servers'));
return;
}
_toolsetsCatalog.forEach(function(name) {
const row = document.createElement('label');
row.className = 'toolsets-server-option';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '6px';
row.style.margin = '4px 0';
row.style.fontSize = '12px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'toolsets-server-checkbox';
checkbox.value = name;
checkbox.checked = selectedSet.has(name);
row.appendChild(checkbox);
row.appendChild(document.createTextNode(name));
section.appendChild(row);
});
}
function _populateToolsetsDropdown() {
const desc = $('toolsetsDropdownDesc');
const state = $('toolsetsDropdownState');
const input = $('toolsetsInput');
const applyBtn = $('toolsetsApplyBtn');
const clearBtn = $('toolsetsClearBtn');
if (!desc || !state || !input) return;
desc.textContent = t('session_toolsets_desc');
if (applyBtn) applyBtn.textContent = t('session_toolsets_apply');
if (clearBtn) clearBtn.textContent = t('session_toolsets_clear');
input.placeholder = t('session_toolsets_placeholder');
// Escape key handler for toolsets input
input.onkeydown = function(e) { if(e.key === 'Escape') closeToolsetsDropdown(); };
input.oninput = function() { _renderToolsetsPresetSections({ state, input }); };
const hasCustom = Array.isArray(_currentSessionToolsets) && _currentSessionToolsets.length > 0;
if (hasCustom) {
input.value = _currentSessionToolsets.join(', ');
} else {
input.value = '';
}
_renderToolsetsPresetSections({ state, input });
}
function _positionToolsetsDropdown() {
const dd = $('composerToolsetsDropdown');
const chip = $('composerToolsetsChip');
const footer = document.querySelector('.composer-footer');
if (!dd || !chip || !footer) return;
// Defense: if the chip has been hidden by responsive CSS (e.g. resize across
// 1100px container threshold while dropdown was open), don't try to anchor
// to a zero-rect element — close the dropdown instead. (#1431)
if (chip.offsetParent === null) { closeToolsetsDropdown(); return; }
const chipRect = chip.getBoundingClientRect();
const footerRect = footer.getBoundingClientRect();
let left = chipRect.left - footerRect.left;
const maxLeft = Math.max(0, footer.clientWidth - dd.offsetWidth);
left = Math.max(0, Math.min(left, maxLeft));
dd.style.left = left + 'px';
}
function toggleToolsetsDropdown() {
const dd = $('composerToolsetsDropdown');
const chip = $('composerToolsetsChip');
if (!dd || !chip) return;
// Don't open when the chip itself is hidden by responsive CSS (#1431).
// offsetParent === null catches display:none on the element or any ancestor.
if (chip.offsetParent === null) return;
const open = dd.classList.contains('open');
if (open) { closeToolsetsDropdown(); return; }
if (typeof closeProfileDropdown === 'function') closeProfileDropdown();
if (typeof closeWsDropdown === 'function') closeWsDropdown();
closeModelDropdown();
if (typeof closeReasoningDropdown === 'function') closeReasoningDropdown();
_syncToolsetsChip();
_populateToolsetsDropdown();
_loadToolsetsCatalog().then(function() {
const stillOpen = dd && dd.classList.contains('open');
if (stillOpen) {
const state = $('toolsetsDropdownState');
const input = $('toolsetsInput');
_renderToolsetsPresetSections({ state, input });
}
});
dd.classList.add('open');
_positionToolsetsDropdown();
chip.classList.add('active');
// Focus the input after a tick so the layout has settled
setTimeout(() => { const inp = $('toolsetsInput'); if (inp) inp.focus(); }, 50);
}
function closeToolsetsDropdown() {
const dd = $('composerToolsetsDropdown');
const chip = $('composerToolsetsChip');
if (dd) dd.classList.remove('open');
if (chip) chip.classList.remove('active');
}
function _applySessionToolsets(toolsets) {
if (typeof S === 'undefined' || !S) return;
if (!S.session) {
S._pendingSessionToolsets = toolsets;
_applyToolsetsChip(toolsets);
if (Array.isArray(toolsets) && toolsets.length) {
showToast('🔧 ' + t('session_toolsets_applied') + ': ' + toolsets.join(', '));
} else {
showToast('🌍 ' + t('session_toolsets_cleared'));
}
return;
}
const sid = S.session.session_id;
api('/api/session/toolsets', {
method: 'POST',
body: JSON.stringify({ session_id: sid, toolsets: toolsets })
})
.then(function(r) {
if (r && r.ok) {
S.session.enabled_toolsets = r.enabled_toolsets || null;
_applyToolsetsChip(r.enabled_toolsets || null);
if (r.enabled_toolsets && r.enabled_toolsets.length) {
showToast('🔧 ' + t('session_toolsets_applied') + ': ' + r.enabled_toolsets.join(', '));
} else {
showToast('🌍 ' + t('session_toolsets_cleared'));
}
} else {
showToast(t('session_toolsets_failed') + (r && r.error ? r.error : 'Unknown error'), 3000, 'error');
}
})
.catch(function(err) {
showToast(t('session_toolsets_failed') + (err.message || err), 3000, 'error');
});
}
// Click-outside handler for toolsets dropdown
document.addEventListener('click', function(e) {
if (
!e.target.closest('#composerToolsetsChip') &&
!e.target.closest('#composerToolsetsDropdown')
) closeToolsetsDropdown();
// Active profile defaults button
if (e.target.closest('#toolsetsProfileDefaultsBtn')) {
_applySessionToolsets(null);
closeToolsetsDropdown();
return;
}
// Apply button
if (e.target.closest('#toolsetsApplyBtn')) {
const input = $('toolsetsInput');
if (!input) return;
const raw = input.value.trim();
if (!raw) {
showToast(t('session_toolsets_desc'), 2000);
return;
}
const toolsets = raw.split(',').map(s => s.trim()).filter(Boolean);
if (toolsets.length === 0) {
showToast(t('session_toolsets_desc'), 2000);
return;
}
_applySessionToolsets(toolsets);
closeToolsetsDropdown();
}
// Clear button
if (e.target.closest('#toolsetsClearBtn')) {
_applySessionToolsets(null);
closeToolsetsDropdown();
}
});
document.addEventListener('change', function(e) {
if (!e.target.closest('#toolsetsPresetSections')) return;
if (!e.target.classList.contains('toolsets-server-checkbox')) return;
const input = $('toolsetsInput');
const state = $('toolsetsDropdownState');
if (!input) return;
const checked = Array.from(document.querySelectorAll('#toolsetsPresetSections .toolsets-server-checkbox:checked'))
.map(el => String(el.value || '').trim())
.filter(Boolean);
const catalogSet = new Set(Array.isArray(_toolsetsCatalog) ? _toolsetsCatalog : []);
const manual = _toolsetsInputList(input).filter(name => !catalogSet.has(name));
input.value = checked.concat(manual).join(', ');
_renderToolsetsPresetSections({ state, input });
});
// Position toolsets dropdown on resize, OR close it if the chip is no longer
// visible (e.g. resize crossed the 1100px container threshold while dropdown
// was open — the wrap is hidden by CSS but the dropdown sibling stays open
// without an anchor). (#1431)
window.addEventListener('resize', () => {
const dd = $('composerToolsetsDropdown');
if (!dd || !dd.classList.contains('open')) return;
const chip = $('composerToolsetsChip');
if (!chip || chip.offsetParent === null) { closeToolsetsDropdown(); return; }
_positionToolsetsDropdown();
});
function _syncMobileComposerConfigButton(open){
const btn=$('composerMobileConfigBtn');
if(!btn) return;
btn.classList.toggle('active',!!open);
btn.setAttribute('aria-expanded',open?'true':'false');
}
function closeMobileComposerConfig(){
const panel=$('composerMobileConfigPanel');
if(panel) panel.classList.remove('open');
_syncMobileComposerConfigButton(false);
if(typeof closeWsDropdown==='function') closeWsDropdown();
}
function toggleMobileComposerConfig(){
const panel=$('composerMobileConfigPanel');
if(!panel) return;
const open=panel.classList.contains('open');
if(open){
closeMobileComposerConfig();
closeModelDropdown();
closeReasoningDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
return;
}
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
closeModelDropdown();
closeReasoningDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
panel.classList.add('open');
_syncMobileComposerConfigButton(true);
}
document.addEventListener('click',function(e){
if(
e.target.closest('#composerMobileConfigBtn') ||
e.target.closest('#composerMobileConfigPanel') ||
e.target.closest('#composerWsDropdown') ||
e.target.closest('#composerModelDropdown') ||
e.target.closest('#composerReasoningDropdown')
) return;
closeMobileComposerConfig();
});
document.addEventListener('keydown',function(e){
if(e.key!=='Escape') return;
const panel=$('composerMobileConfigPanel');
if(!panel||!panel.classList.contains('open')) return;
e.preventDefault();
closeMobileComposerConfig();
if(typeof closeWsDropdown==='function') closeWsDropdown();
closeModelDropdown();
closeReasoningDropdown();
});
window.addEventListener('resize',function(){
if(window.matchMedia && !window.matchMedia('(max-width: 640px)').matches){
closeMobileComposerConfig();
closeModelDropdown();
closeReasoningDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
}
});
// ── Scroll pinning ──────────────────────────────────────────────────────────
// When streaming, auto-scroll only while the user is following the live tail.
// Any manual scroll up sets a sticky unpinned flag until the user scrolls back
// to the bottom (near-bottom hysteresis on downward motion) or clicks ↓.
// Programmatic scrolls are ignored via _programmaticScroll. Fixes #1469 / #1360 / #1731.
let _scrollPinned=true;
let _programmaticScroll=false;
let _programmaticScrollSetAt=0;
let _programmaticScrollResetTimer=0;
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
let _nearBottomCount=0;
let _lastScrollTop=null;
let _lastMessageClientHeight=null; // #4702: track scroller height to ignore iOS portrait toolbar-settle reflows (a clientHeight increase fires a scroll event with decreased scrollTop that is NOT a user scroll)
// Sticky-unpin model (#3343 supersedes #3330's proximity re-pin): once the user
// scrolls up, streaming stops auto-following until they return to the bottom or
// click ↓. The upward-intent TIMEOUT mechanism (_lastMessageUpwardIntentMs /
// MESSAGE_UPWARD_INTENT_MS) is removed — sticky-unpin makes it unnecessary.
// Keep the non-message intent timestamp at -Infinity so load-time isn't read as
// intent (the #3330 follow-up fix); 0 would mark the first NON_MESSAGE_SCROLL_INTENT
// window after load as suppressed.
let _lastNonMessageScrollIntentMs=-Infinity;
let _messageUserUnpinned=false;
let _bottomSettleToken=0;
let _settleRAF=0;
let _settleRO=null;
let _settleTimer=0;
let _settleFinalTimer=0;
const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350;
let _touchStartY=null;
let _messageTouchScrollActive=false;
let _lastMessageTouchScrollIntentMs=-Infinity;
let _deferredOlderMessagesTimer=0;
const MESSAGE_TOUCH_SCROLL_SUPPRESS_MS=1200;
let _newMessageCueVisible=false;
function _cancelBottomSettle(){ _bottomSettleToken++; if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } clearTimeout(_settleTimer); clearTimeout(_settleFinalTimer); cancelAnimationFrame(_settleRAF); }
function _markMessageTouchScrollIntent(active=true){
_messageTouchScrollActive=!!active;
_lastMessageTouchScrollIntentMs=performance.now();
}
function _recentMessageTouchScrollIntent(){
return _messageTouchScrollActive || performance.now()-_lastMessageTouchScrollIntentMs{
_deferredOlderMessagesTimer=0;
if(_recentMessageTouchScrollIntent()){
_scheduleDeferredOlderMessagesLoad();
return;
}
if(_olderMessagesPrefetchReady()) _loadOlderMessages();
},MESSAGE_TOUCH_SCROLL_SUPPRESS_MS+50);
}
function _recordNonMessageScrollIntent(e){
const el=document.getElementById('messages');
const target=e&&e.target;
if(!el||!target) return;
if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now();
else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY< -30)){
_cancelBottomSettle();
if(e.type==='touchmove') _markMessageTouchScrollIntent(true);
if(typeof e.deltaY==='number'&&e.deltaY< -30){
_messageUserUnpinned=true;
_nearBottomCount=0;
_scrollPinned=false;
} else if(e.type==='touchmove'&&_touchStartY!==null&&e.touches&&e.touches[0]){
// Detect upward-scroll intent on touch: dragging the finger DOWN the
// screen scrolls the content up into earlier history (scrollTop
// decreases) — the same "user scrolled away" signal the wheel deltaY<0
// branch and the scroll listener's movedUp branch use. dy>0 = finger
// moved down = reveal earlier content = unpin.
const dy=e.touches[0].clientY-_touchStartY;
if(dy>8){
_messageUserUnpinned=true;
_nearBottomCount=0;
_scrollPinned=false;
}
}
}
}
function _recentNonMessageScrollIntent(){
return performance.now()-_lastNonMessageScrollIntentMspreviousHeight+24 && distance>80) _showNewMessageScrollCue();
else _syncScrollToBottomCue(distance>80,{newMessage:_newMessageCueVisible});
}
if(typeof document!=='undefined'){
document.addEventListener('wheel',_recordNonMessageScrollIntent,{capture:true,passive:true});
document.addEventListener('touchmove',_recordNonMessageScrollIntent,{capture:true,passive:true});
document.addEventListener('touchstart',function(e){
const el=document.getElementById('messages');
if(e.touches&&e.touches[0]) _touchStartY=e.touches[0].clientY;
if(el&&e.target&&el.contains(e.target)) _markMessageTouchScrollIntent(true);
},{capture:true,passive:true});
document.addEventListener('touchend',function(){ _touchStartY=null; if(_messageTouchScrollActive) _markMessageTouchScrollIntent(false); },{capture:true,passive:true});
document.addEventListener('touchcancel',function(){ _touchStartY=null; if(_messageTouchScrollActive) _markMessageTouchScrollIntent(false); },{capture:true,passive:true});
}
// Reset hook for session-switch — called from sessions.js loadSession() to
// prevent the new chat's first scroll comparing against the previous chat's
// scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
function _resetScrollDirectionTracker(){
_clearNewMessageScrollCue();
_lastScrollTop=null;
_lastMessageClientHeight=null;
_messageUserUnpinned=false;
_scrollPinned=true;
_nearBottomCount=0;
_touchStartY=null;
_messageTouchScrollActive=false;
_lastMessageTouchScrollIntentMs=-Infinity;
clearTimeout(_deferredOlderMessagesTimer);
_deferredOlderMessagesTimer=0;
}
function _resetStreamScrollFollow(){
_clearNewMessageScrollCue();
_messageUserUnpinned=false;
_scrollPinned=true;
_nearBottomCount=0;
_lastScrollTop=null;
_cancelBottomSettle();
}
if(typeof window!=='undefined'){
window._resetScrollDirectionTracker=_resetScrollDirectionTracker;
window._resetStreamScrollFollow=_resetStreamScrollFollow;
}
/* ── Pull-to-refresh for PWA standalone (Android) ── */
(function(){
if(typeof document==='undefined') return;
const isStandalone=window.navigator?.standalone||matchMedia('(display-mode:standalone),(display-mode:fullscreen)').matches;
if(!isStandalone) return;
const el=document.getElementById('messages');
if(!el) return;
let _ptrState=0; // 0=idle, 1=pulling, 2=ready
let _ptrStartY=0;
let _ptrCurrentY=0;
const THRESHOLD=80;
let _indicator=null;
function _ptrCreateIndicator(){
if(_indicator) return;
_indicator=document.createElement('div');
_indicator.className='pull-to-refresh-indicator';
_indicator.innerHTML='↓ Pull to refresh ';
el.parentNode.insertBefore(_indicator,el);
}
function _ptrUpdate(progress){
_ptrCreateIndicator();
const pulling=progress<1;
_indicator.classList.toggle('active',progress>0);
const icon=_indicator.querySelector('.ptr-icon');
const text=_indicator.querySelector('.ptr-text');
if(icon) icon.classList.toggle('ready',!pulling);
if(text) text.textContent=pulling?'Pull to refresh':'Release to refresh';
}
function _ptrReset(){
_ptrState=0;
_ptrStartY=0;
_ptrCurrentY=0;
if(_indicator) _indicator.classList.remove('active');
}
el.addEventListener('touchstart',function(e){
if(el.scrollTop>0||_ptrState!==0) return;
_ptrStartY=e.touches[0].clientY;
_ptrState=1;
},{passive:true});
el.addEventListener('touchmove',function(e){
if(_ptrState!==1) return;
_ptrCurrentY=e.touches[0].clientY;
const pull=_ptrCurrentY-_ptrStartY;
if(pull<0){ _ptrReset(); return; }
/* If not at the top, smooth-scroll to top first.
Next pull gesture will trigger the refresh. */
if(el.scrollTop>0){
el.scrollTo({top:0,behavior:'smooth'});
_ptrReset();
return;
}
const progress=Math.min(pull/THRESHOLD,1);
_ptrUpdate(progress);
_ptrState=progress>=1?2:1;
if(progress>0.3) e.preventDefault();
},{passive:false});
el.addEventListener('touchend',function(){
if(_ptrState===2){
if(typeof window.refreshSessionList==='function'){
Promise.resolve(window.refreshSessionList('pull', {force:true, refreshActive:true})).catch(()=>{}).finally(_ptrReset);
}else{
window.location.reload();
}
return;
}
_ptrReset();
},{passive:true});
el.addEventListener('touchcancel',_ptrReset,{passive:true});
})();
(function(){
const el=document.getElementById('messages');
if(!el) return;
el.addEventListener('pointerdown',(e)=>{
if(e.target===el&&e.offsetX>=el.clientWidth) _scrollbarDragActive=true;
},{passive:true});
window.addEventListener('pointerup',()=>{
if(!_scrollbarDragActive) return;
_scrollbarDragActive=false;
_scheduleMessageVirtualizedRender(true);
},{passive:true});
window.addEventListener('pointercancel',()=>{
if(!_scrollbarDragActive) return;
_scrollbarDragActive=false;
_scheduleMessageVirtualizedRender(true);
},{passive:true});
window.addEventListener('blur',()=>{ _scrollbarDragActive=false; },{passive:true});
document.addEventListener('visibilitychange',()=>{
if(document.visibilityState==='hidden') _scrollbarDragActive=false;
},{passive:true});
let _scrollRaf=0;
el.addEventListener('scroll',()=>{
_scheduleMessageVirtualizedRender();
if(_programmaticScroll&&(performance.now()-_programmaticScrollSetAt)>150) _programmaticScroll=false;
if(_programmaticScroll) return;
_markMessageVirtualScrollActive();
cancelAnimationFrame(_scrollRaf);
_scrollRaf=requestAnimationFrame(()=>{
const top=el.scrollTop;
const bottomDistance=el.scrollHeight-top-el.clientHeight;
const nearBottom=bottomDistance<250;
// #4702: iOS Safari (esp. portrait) resolves its dynamic toolbar height
// AFTER first paint. When the toolbar collapses the scroller GROWS
// (clientHeight increases), which fires a scroll event with a DECREASED
// scrollTop even though the user never scrolled. Without this guard that
// reflow is misread as an upward scroll and falsely unpins a freshly-opened
// session, stranding portrait readers at the top (sibling: #4701). On
// desktop/landscape the scroller height is stable, so `grew` is always
// false and behavior is byte-identical.
const grew=_lastMessageClientHeight!==null&&el.clientHeight>_lastMessageClientHeight+1;
_lastMessageClientHeight=el.clientHeight;
const movedUp=!grew&&_lastScrollTop!==null&&top<_lastScrollTop-2;
const movedDown=_lastScrollTop!==null&&top>_lastScrollTop+2;
_lastScrollTop=top;
if(movedUp){
_cancelBottomSettle();
_nearBottomCount=0;
_scrollPinned=false;
_messageUserUnpinned=true;
}else if(movedDown&&nearBottom){
_nearBottomCount=_nearBottomCount+1;
if(_nearBottomCount>=2){
if(!_messageUserUnpinned||bottomDistance<=80){
_messageUserUnpinned=false;
_scrollPinned=true;
}
_nearBottomCount=0;
}
}else if(!_messageUserUnpinned){
if(nearBottom){
_nearBottomCount=_nearBottomCount+1;
if(_nearBottomCount>=2){_scrollPinned=true;_nearBottomCount=0;}
}else{
_nearBottomCount=0;
_scrollPinned=false;
}
}else if(!nearBottom){
_nearBottomCount=0;
_scrollPinned=false;
}
if(nearBottom) _clearNewMessageScrollCue();
const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80;
_syncScrollToBottomCue(showBottomButton,{newMessage:_newMessageCueVisible});
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
// Prefetch older messages before the reader hits the hard top. Prepending
// then preserving scrollTop is seamless only if there is runway left for
// the user's continued upward wheel/touch movement.
const olderPrefetchPx=Math.max(600,el.clientHeight*1.5);
if(_isSessionEndlessScrollEnabled()&&el.scrollTop=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);}
function _formatTurnDuration(seconds){
const n=Number(seconds);
if(!Number.isFinite(n)||n<0)return'';
const total=Math.max(0,Math.round(n));
if(total<60)return`${total}s`;
const h=Math.floor(total/3600);
const m=Math.floor((total%3600)/60);
const s=total%60;
if(h)return`${h}h ${m}m`;
return`${m}m ${s}s`;
}
function _formatFirstToken(ms){
const n=Number(ms);
if(!Number.isFinite(n)||n<0)return'';
if(n<1000)return`${Math.round(n)}ms`;
return`${(n/1000).toFixed(2)}s`;
}
function _formatActiveElapsedTimer(seconds){
const n=Number(seconds);
if(!Number.isFinite(n)||n<0)return'';
const total=Math.max(0,Math.floor(n));
const m=Math.floor(total/60);
const s=total%60;
return`${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
function _processedElapsedLabel(seconds){
const text=_formatTurnDuration(seconds);
return text?t('processed_elapsed',text):'';
}
const _COMPRESSION_ELAPSED_MAX_SECONDS=5*60;
let _compressionElapsedTimer=null;
function _compressionElapsedStartedAt(state){const n=Number(state&&state.startedAt);return Number.isFinite(n)&&n>0?n:null;}
function _compressionElapsedLabel(state){
const started=_compressionElapsedStartedAt(state);
if(!started)return'';
const elapsed=Math.max(0,(Date.now()/1000)-started);
if(elapsed>=_COMPRESSION_ELAPSED_MAX_SECONDS)return '5+ min';
return _formatActiveElapsedTimer(elapsed);
}
function _compressionElapsedExpired(state){const started=_compressionElapsedStartedAt(state);return !!(started&&((Date.now()/1000)-started)>=_COMPRESSION_ELAPSED_MAX_SECONDS);}
function _compressionLiveCardNode(){return document.querySelector('[data-live-compression-card="1"][data-compression-started-at]');}
function _compressionLiveCardState(){
const node=_compressionLiveCardNode();
const started=Number(node&&node.getAttribute('data-compression-started-at'));
if(!node||!S.session||!Number.isFinite(started)||started<=0)return null;
return {sessionId:S.session.session_id,phase:'running',automatic:true,message:node.getAttribute('data-compression-message')||'Auto-compressing context...',startedAt:started};
}
function _updateCompressionElapsedCards(state){
if(!state)return false;
return false;
}
function _updateCompressionElapsedTimer(){
const state=_compressionStateForCurrentSession()||_compressionLiveCardState();
if(state&&state.automatic&&state.phase==='running'){
_updateCompressionElapsedCards(state);
if(_compressionElapsedExpired(state)) _clearCompressionElapsedTimer();
}else _clearCompressionElapsedTimer();
}
function _startCompressionElapsedTimer(){if(!_compressionElapsedTimer)_compressionElapsedTimer=setInterval(_updateCompressionElapsedTimer,1000);}
function _clearCompressionElapsedTimer(){if(_compressionElapsedTimer){clearInterval(_compressionElapsedTimer);_compressionElapsedTimer=null;}}
let _activityElapsedTimer=null;
let _activityElapsedTimerGroup=null;
function _activityNowSeconds(){return Date.now()/1000;}
function _isActivityTimerGroup(group){
return !!(group&&group.getAttribute('data-run-activity-group')==='1');
}
function _activityElapsedStartedAt(group){
if(!group)return null;
const raw=(group.dataset&&group.dataset.turnStartedAt!==undefined&&group.dataset.turnStartedAt!=='')
?group.dataset.turnStartedAt
:(S.session&&S.session.pending_started_at);
const started=Number(raw);
return Number.isFinite(started)&&started>0?started:null;
}
function _activityElapsedLabel(group){
const started=_activityElapsedStartedAt(group);
if(!started)return'';
return _formatActiveElapsedTimer(_activityNowSeconds()-started);
}
function _activityProcessedElapsedLabel(group){
const started=_activityElapsedStartedAt(group);
if(!started)return'';
return _processedElapsedLabel(_activityNowSeconds()-started);
}
function _activitySettledProcessedLabel(group){
let durationText=_formatTurnDuration(group&&group.dataset&&group.dataset.turnDuration);
if(!durationText&&group){
const durationEl=group.querySelector&&group.querySelector('.tool-call-group-duration');
const legacy=String(durationEl&&durationEl.textContent||'').replace(/^\s*Done in\s+/i,'').trim();
if(legacy) durationText=legacy;
}
return durationText?t('processed_elapsed',durationText):'';
}
function _activityMarkObserved(group, ts){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
const stamp=Number(ts||_activityNowSeconds());
if(Number.isFinite(stamp)&&stamp>0) group.setAttribute('data-last-activity-at',String(stamp));
}
function _activityLastObservedAge(group){
const stamp=Number(group&&group.getAttribute('data-last-activity-at'));
if(!Number.isFinite(stamp)||stamp<=0)return null;
return Math.max(0,_activityNowSeconds()-stamp);
}
function _activityClockLabel(ts){
const stamp=Number(ts||_activityNowSeconds());
if(!Number.isFinite(stamp)||stamp<=0)return'';
try{return new Date(stamp*1000).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});}catch(_){return'';}
}
function _activityStatusNode({kind='info',label='',detail='',status='done',ts=null,id=''}){
const row=document.createElement('div');
row.className=`agent-activity-status agent-activity-status-${kind} agent-activity-status-${status}`;
if(id) row.setAttribute('data-activity-event-id',id);
if(ts) row.setAttribute('data-activity-at',String(ts));
const iconMap={run:li('play',13),model:li('bot',13),waiting:' ',thinking:li('lightbulb',13),tool:li('wrench',13),done:li('check',13),warning:li('alert-triangle',13)};
row.innerHTML=`${iconMap[kind]||li('clock',13)} ${esc(label)} ${detail?`${esc(detail)} `:''}${esc(_activityClockLabel(ts))} `;
return row;
}
function _appendActivityEvent(group, event){
if(!group)return null;
const body=group.querySelector('.tool-call-group-body');
if(!body)return null;
const eventId=event&&event.id;
let row=eventId?body.querySelector(`.agent-activity-status[data-activity-event-id="${CSS.escape(eventId)}"]`):null;
const next=_activityStatusNode(event||{});
if(row){row.replaceWith(next);row=next;}
else{body.appendChild(next);row=next;}
_activityMarkObserved(group,event&&event.ts);
return row;
}
function _ensureLiveActivityBaseline(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
const started=_activityElapsedStartedAt(group)||_activityNowSeconds();
if(!group.getAttribute('data-turn-started-at')) group.setAttribute('data-turn-started-at',String(started));
if(!group.getAttribute('data-last-activity-at')) group.setAttribute('data-last-activity-at',String(started));
_appendActivityEvent(group,{id:'run-started',kind:'run',label:'Run started',detail:'Observable activity will appear here as the agent works.',status:'done',ts:started});
const modelLabel=(S.session&&S.session.model)?getModelLabel(S.session.model):'';
if(modelLabel)_appendActivityEvent(group,{id:'run-model',kind:'model',label:`Model: ${modelLabel}`,detail:S.activeProfile&&S.activeProfile!=='default'?`Profile: ${S.activeProfile}`:'',status:'done',ts:started});
}
function _setActivityElapsedStartedAt(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
const started=_activityElapsedStartedAt(group);
if(started)group.setAttribute('data-turn-started-at',String(started));
}
function _updateActiveActivityElapsedTimer(){
const group=_activityElapsedTimerGroup;
if(!group||!group.isConnected||group.getAttribute('data-live-tool-call-group')!=='1'||group.getAttribute('data-live-activity-current')!=='1'){
_clearActivityElapsedTimer();
return;
}
const durationEl=group.querySelector('.tool-call-group-duration');
const label=_activityElapsedLabel(group);
const processedLabel=_activityProcessedElapsedLabel(group);
if(label){
group.setAttribute('data-active-turn-elapsed',label);
}else{
group.removeAttribute('data-active-turn-elapsed');
}
const labelEl=group.querySelector('.tool-worklog-label') || group.querySelector('.tool-call-group-label');
if(labelEl&&processedLabel){
labelEl.textContent=processedLabel;
labelEl.setAttribute('data-sweep-label', processedLabel);
}
if(durationEl){
durationEl.textContent='';
durationEl.style.display='none';
}
}
function _startActivityElapsedTimer(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
_setActivityElapsedStartedAt(group);
// Last-resort fallback for recovered live renders that arrive before session metadata.
if(!group.getAttribute('data-turn-started-at')) group.setAttribute('data-turn-started-at',String(_activityNowSeconds()));
if(_activityElapsedTimerGroup&&_activityElapsedTimerGroup!==group)_clearActivityElapsedTimer();
_activityElapsedTimerGroup=group;
_updateActiveActivityElapsedTimer();
if(!_activityElapsedTimer)_activityElapsedTimer=setInterval(_updateActiveActivityElapsedTimer,1000);
}
function _clearActivityElapsedTimer(){
if(_activityElapsedTimer){
clearInterval(_activityElapsedTimer);
_activityElapsedTimer=null;
}
if(_activityElapsedTimerGroup&&_activityElapsedTimerGroup.isConnected){
_activityElapsedTimerGroup.removeAttribute('data-active-turn-elapsed');
const durationEl=_activityElapsedTimerGroup.querySelector('.tool-call-group-duration');
if(durationEl){durationEl.textContent='';durationEl.style.display='none';}
}
_activityElapsedTimerGroup=null;
}
const _MOBILE_CONFIG_BASE_LABEL='Workspace, model, reasoning, and context settings';
function _setCtxCompressButton(btn,text){
if(!btn)return;
if(text){
btn.style.display='';
btn.textContent=text;
btn.onclick=function(e){
if(e)e.stopPropagation();
const ta=$('msg');
if(ta){ta.value='/compress ';ta.focus();autoResize();}
};
}else{
btn.style.display='none';
btn.textContent='';
btn.onclick=null;
}
}
function _syncMobileCtxDisplay(state){
const mobileConfigBtn=$('composerMobileConfigBtn');
const row=$('composerMobileContextAction');
const usageLine=$('composerMobileContextUsage');
const tokensLine=$('composerMobileContextTokens');
const thresholdLine=$('composerMobileContextThreshold');
const costLine=$('composerMobileContextCost');
const compressBtn=$('composerMobileCtxCompressBtn');
if(!state||!state.visible){
if(row)row.style.display='none';
if(mobileConfigBtn){
mobileConfigBtn.setAttribute('aria-label',_MOBILE_CONFIG_BASE_LABEL);
mobileConfigBtn.setAttribute('title',_MOBILE_CONFIG_BASE_LABEL);
}
_setCtxCompressButton(compressBtn,'');
// Reset context ring to 0% to clear any stale values from previous sessions
var arc = document.getElementById('ctx-arc');
var num = document.getElementById('ctx-num');
if (arc && num) {
var circumference = 87.96;
arc.setAttribute('stroke-dashoffset', circumference);
num.textContent = '0';
arc.setAttribute('stroke', '#22c55e');
}
return;
}
(function updateCtxRing(pct) {
var arc = document.getElementById('ctx-arc');
var num = document.getElementById('ctx-num');
if (!arc || !num) return;
var offset = 87.96 * (1 - Math.min(pct, 100) / 100);
arc.setAttribute('stroke-dashoffset', offset);
num.textContent = Math.round(pct);
arc.setAttribute('stroke',
pct <= 50 ? '#22c55e' : pct <= 85 ? '#f97316' : '#ef4444'
);
})(state.pct);
if(mobileConfigBtn){
mobileConfigBtn.setAttribute('aria-label',`${_MOBILE_CONFIG_BASE_LABEL}; ${state.label}`);
mobileConfigBtn.setAttribute('title',`${_MOBILE_CONFIG_BASE_LABEL} \u00b7 ${state.label}`);
}
if(row){
row.style.display='';
row.setAttribute('aria-label',state.label);
row.classList.toggle('ctx-mid',state.pct>50&&state.pct<=75);
row.classList.toggle('ctx-high',state.pct>75);
}
if(usageLine)usageLine.textContent=state.usageText||'';
if(tokensLine)tokensLine.textContent=state.tokensText||'';
if(thresholdLine){
if(state.thresholdText){
thresholdLine.style.display='';
thresholdLine.textContent=state.thresholdText;
}else{
thresholdLine.style.display='none';
thresholdLine.textContent='';
}
}
if(costLine){
if(state.costText){
costLine.style.display='';
costLine.textContent=state.costText;
}else{
costLine.style.display='none';
costLine.textContent='';
}
}
_setCtxCompressButton(compressBtn,state.compressText||'');
}
function _mergeUsageForCtxIndicator(latest, fallback){
const latestObj=(latest&&typeof latest==='object')?latest:{};
const fallbackObj=(fallback&&typeof fallback==='object')?fallback:{};
const merged={...latestObj};
for(const field of [
'input_tokens','output_tokens','estimated_cost',
'cache_read_tokens','cache_write_tokens','cache_hit_percent',
'turn_cache_hit_percent','duration_seconds','tps','gateway_routing',
]){
if(merged[field]==null&&fallbackObj[field]!=null){
merged[field]=fallbackObj[field];
}
}
if(!(Number(latestObj.context_length)>0)&&Number(fallbackObj.context_length)>0){
merged.context_length=fallbackObj.context_length;
}
for(const field of ['threshold_tokens','last_prompt_tokens']){
if(latestObj[field]==null&&fallbackObj[field]!=null){
merged[field]=fallbackObj[field];
}
}
return merged;
}
// Context usage indicator in composer footer
function _syncCtxIndicator(usage){
const wrap=$('ctxIndicatorWrap');
const el=$('ctxIndicator');
if(!el)return;
const ctxHidden=!!(window._composerControlVisibility&&window._composerControlVisibility.hide_composer_context);
if(ctxHidden){
if(wrap) wrap.style.display='none';
_syncMobileCtxDisplay({visible:false});
return;
}
// #1436: Use last_prompt_tokens only — NEVER fall back to cumulative
// input_tokens for the "context window % used" calculation. input_tokens
// is summed across all turns, so dividing it by the context window gives a
// nonsense percentage (often >100%) on long sessions. When we have no
// last-prompt data we render "·" + "tokens used" via the !hasPromptTok
// branch below — honest "no data" instead of misleading "890% used".
const promptTok=usage.last_prompt_tokens||0;
const totalTok=(usage.input_tokens||0)+(usage.output_tokens||0);
const cacheReadTok=usage.cache_read_tokens||0;
const cacheWriteTok=usage.cache_write_tokens||0;
// Default context window to 128K when not provided by backend
const DEFAULT_CTX=128*1024;
const ctxWindow=usage.context_length||DEFAULT_CTX;
const cost=usage.estimated_cost;
// Show indicator whenever we have any usage data (tokens or cost)
if(!promptTok&&!totalTok&&!cost&&!cacheReadTok&&!cacheWriteTok){
if(wrap) wrap.style.display='none';
_syncMobileCtxDisplay({visible:false});
return;
}
if(wrap){
// Defensive reset: keep dynamic context display from being stuck hidden.
wrap.classList.remove('composer-control-hidden');
wrap.removeAttribute('aria-hidden');
wrap.style.display='';
}
const hasPromptTok=!!promptTok;
const rawPct=hasPromptTok?Math.round((promptTok/ctxWindow)*100):0;
const pct=Math.min(100,rawPct);
const overflowed=rawPct>100;
const ring=$('ctxRingValue');
const center=$('ctxPercent');
const usageLine=$('ctxTooltipUsage');
const tokensLine=$('ctxTooltipTokens');
const thresholdLine=$('ctxTooltipThreshold');
const costLine=$('ctxTooltipCost');
if(ring){
const circumference=61.261056745;
ring.style.strokeDasharray=String(circumference);
ring.style.strokeDashoffset=String(circumference*(1-pct/100));
}
if(center) center.textContent=hasPromptTok?String(pct):'\u00b7';
const hasExplicitCtx=!!usage.context_length;
el.classList.toggle('ctx-mid',pct>50&&pct<=75);
el.classList.toggle('ctx-high',pct>75);
// ── Compress affordance (#524) ──
// Show a hint in the tooltip when context usage is high so users
// discover /compress without having to know the slash command.
const compressWrap=$('ctxTooltipCompress');
const compressBtn=$('ctxCompressBtn');
const compressText=pct>=75?t('ctx_compress_action'):(pct>=50?t('ctx_compress_hint'):'');
if(compressWrap) compressWrap.style.display=compressText?'':'none';
_setCtxCompressButton(compressBtn,compressText);
const cacheHitPct=usage.cache_hit_percent;
const cacheText=cacheHitPct!=null?t('usage_cache_hit_detail',cacheHitPct,_fmtTokens(cacheReadTok),_fmtTokens(cacheWriteTok)):'';
let label=hasPromptTok?`Context window ${pct}% used`:`${_fmtTokens(totalTok)} tokens used`;
if(!hasExplicitCtx&&hasPromptTok) label+=' (est. 128K)';
if(cost) label+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
if(cacheText) label+=` \u00b7 ${cacheText}`;
el.setAttribute('aria-label',label);
const usageText=hasPromptTok?(overflowed?`${rawPct}% used (context exceeded)`:`${pct}% used (${100-pct}% left)`):`${_fmtTokens(totalTok)} tokens used`;
const tokensText=hasPromptTok?`${_fmtTokens(promptTok)} / ${_fmtTokens(ctxWindow)} tokens used`:`In: ${_fmtTokens(usage.input_tokens||0)} \u00b7 Out: ${_fmtTokens(usage.output_tokens||0)}`;
if(usageLine) usageLine.textContent=usageText;
if(tokensLine) tokensLine.textContent=tokensText;
const threshold=usage.threshold_tokens||0;
let thresholdText='';
if(thresholdLine){
if(threshold&&ctxWindow){
thresholdText=`Auto-compress at ${_fmtTokens(threshold)} (${Math.round(threshold/ctxWindow*100)}%)`;
thresholdLine.style.display='';
thresholdLine.textContent=thresholdText;
}else{
thresholdLine.style.display='none';
thresholdLine.textContent='';
}
}
let costText='';
if(costLine){
if(cost){
costText=`Estimated cost: $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
if(cacheText) costText+=` \u00b7 ${cacheText}`;
costLine.style.display='';
costLine.textContent=costText;
}else if(cacheText){
costText=cacheText;
costLine.style.display='';
costLine.textContent=costText;
}else{
costLine.style.display='none';
costLine.textContent='';
}
}
_syncMobileCtxDisplay({
visible:true,
hasPromptTok,
pct,
label,
usageText,
tokensText,
thresholdText,
costText,
compressText
});
}
// ── Touch support: toggle context tooltip on tap (#524) ──
// On mobile, hover doesn't work — allow tap on the context ring button
// to toggle the tooltip visibility so the compress affordance is reachable.
document.addEventListener('DOMContentLoaded',function(){
const wrap=document.getElementById('ctxIndicatorWrap');
const tooltip=document.getElementById('ctxTooltip');
if(!wrap||!tooltip)return;
const btn=document.getElementById('ctxIndicator');
if(!btn)return;
btn.addEventListener('click',function(e){
e.stopPropagation();
const isOpen=tooltip.classList.contains('ctx-tooltip-active');
tooltip.classList.toggle('ctx-tooltip-active',!isOpen);
tooltip.setAttribute('aria-hidden',String(isOpen));
});
// Close on outside tap
document.addEventListener('click',function(){
tooltip.classList.remove('ctx-tooltip-active');
tooltip.setAttribute('aria-hidden','true');
},{passive:true});
// Prevent tooltip click from closing itself
tooltip.addEventListener('click',function(e){e.stopPropagation();});
});
function _setMessageScrollToBottom(){
const el=$('messages');
if(!el) return;
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
el.scrollTop=el.scrollHeight;
_lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight;
_nearBottomCount=2;
_scrollPinned=true;
requestAnimationFrame(()=>{
// Retry the bottom write on the next layout frame so a DOM rebuild that
// grows the transcript after the first write doesn't strand a pinned
// conversation mid-scroll (#3319). But by this frame the user may have
// scrolled up — under the sticky-unpin model (#3343) _messageUserUnpinned
// is the authoritative "user scrolled away" signal, so DON'T snap them back
// or re-pin if so; only release the programmatic-scroll latch.
if(_messageUserUnpinned || !_scrollPinned || _recentNonMessageScrollIntent()){
_deferClearProgrammaticScroll();
return;
}
el.scrollTop=el.scrollHeight;
_lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight;
_nearBottomCount=2;
_scrollPinned=true;
_deferClearProgrammaticScroll();
});
}
function _isMessagePaneNearBottom(threshold=250){
const el=$('messages');
if(!el) return false;
return el.scrollHeight-el.scrollTop-el.clientHeight<=threshold;
}
function _messageBottomDistance(){
const el=$('messages');
if(!el) return 0;
return el.scrollHeight-el.scrollTop-el.clientHeight;
}
function _shouldFollowMessagesOnDomReplace(){
// Final stream settlement replaces the live DOM with persisted messages. Keep
// following only for users who are still pinned or effectively at the tail.
// A broad near-bottom window causes long answers/mobile readers who scroll up
// a little to read mid-stream to get snapped back to the bottom on completion.
return _autoScrollFollow && !_messageUserUnpinned && (_scrollPinned || _isMessagePaneNearBottom(120));
}
function _followMessagesAfterDomReplace(){
if(_shouldFollowMessagesOnDomReplace()){
scrollToBottom();
return true;
}
return false;
}
function _settleMessageScrollToBottom(force, explicit){
// `explicit` = a user-invoked scroll-to-bottom (End button / scrollToBottom()).
// When explicit, late-layout settling runs even if Auto-follow is OFF — the
// setting only suppresses AUTOMATIC streaming follow, not a deliberate jump
// to the bottom. (Codex #4006 r3.)
// can grow the transcript after the first scroll write. Re-apply the bottom
// position when content settles so late layout does not leave the viewport
// above the real end. User scroll increments _bottomSettleToken and cancels.
//
// Firefox paints each scrollTop write as a visible reflow step. The old
// rAF-polling approach read scrollHeight across frames — the read itself
// forced a reflow in Firefox, causing visible jitter.
//
// ResizeObserver approach: the browser notifies us when the container
// resizes (no scrollHeight polling needed). On each notification we write
// scrollTop once via rAF (batches multiple resize callbacks per frame into
// a single write). After 300ms of no resize events, the observer disconnects.
const token=++_bottomSettleToken;
cancelAnimationFrame(_settleRAF);
if(_settleRO){ _settleRO.disconnect(); _settleRO=null; }
clearTimeout(_settleTimer);
clearTimeout(_settleFinalTimer);
// Sync write anchors the viewport immediately.
_setMessageScrollToBottom();
if(force) return;
const el=document.getElementById('messages');
if(!el) return;
// Observe the GROWING content node, not the scroll container. #messages is the
// scroller but its box is fixed by the flex layout, so it never resizes — the
// transcript grows inside #msgInner (.messages-inner). Observing #messages
// would mean the callback never fires. (Codex review #2.)
const observed=document.getElementById('msgInner')||el;
// Instance-owned cleanup: close over THIS observer so a stale callback (from a
// superseded settle) only ever disconnects its own observer, never the newer
// active one that may now be in the global _settleRO. (Codex review #3.)
const ro=new ResizeObserver(()=>{
if(token!==_bottomSettleToken){ ro.disconnect(); if(_settleRO===ro) _settleRO=null; return; }
if((!_autoScrollFollow&&!explicit)||!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent()){
ro.disconnect(); if(_settleRO===ro) _settleRO=null;
_programmaticScroll=false;
return;
}
// Write scrollTop once per frame — ResizeObserver batches multiple
// notifications per frame, so this is at most one write per frame.
cancelAnimationFrame(_settleRAF);
_settleRAF=requestAnimationFrame(()=>{
if(token!==_bottomSettleToken) return;
_setMessageScrollToBottom();
});
// After 300ms of quiet, disconnect — layout is stable.
clearTimeout(_settleTimer);
_settleTimer=setTimeout(()=>{
if(token!==_bottomSettleToken) return;
ro.disconnect(); if(_settleRO===ro) _settleRO=null;
_setMessageScrollToBottom();
},300);
});
_settleRO=ro;
ro.observe(observed);
// #4702: for an explicit (user/open) settle, also observe the SCROLLER itself.
// On iOS the transcript content (#msgInner) may not resize, but the scroller
// grows when the portrait toolbar collapses after first paint — observing both
// re-anchors the bottom after that late viewport settle. Desktop never resizes
// here, so this is a no-op off-mobile.
if(explicit&&observed!==el){ try{ ro.observe(el); }catch(_){ } }
// Static-content safety net: a fully-static response (no Prism/KaTeX/Mermaid/
// late images) never resizes after the initial sync write, so the
// ResizeObserver callback above never fires and its 300ms quiet-timer is never
// armed. Arm a single 2s top-level fallback so a late settle still runs for
// that case. The token check inside _settleFinalScroll makes this a no-op if a
// newer settle started, and it self-skips if the user unpinned. (Review #2/#3.)
clearTimeout(_settleFinalTimer);
_settleFinalTimer=setTimeout(()=>{
if(token!==_bottomSettleToken) return;
ro.disconnect(); if(_settleRO===ro) _settleRO=null;
if((!_autoScrollFollow&&!explicit)||!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent()){ _programmaticScroll=false; return; }
_settleFinalScroll(token);
},2000);
}
function _settleFinalScroll(token){
if(token!==_bottomSettleToken) return;
const el=document.getElementById('messages');
if(!el){ _programmaticScroll=false; return; }
if(_messageUserUnpinned||!_scrollPinned||_recentNonMessageScrollIntent()||_recentMessageTouchScrollIntent()){
_programmaticScroll=false;
return;
}
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
el.scrollTop=el.scrollHeight;
_lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight;
_nearBottomCount=2;
_scrollPinned=true;
_deferClearProgrammaticScroll();
}
function scrollIfPinned(){
if(!_autoScrollFollow) return;
if(_messageUserUnpinned) return;
if(!_scrollPinned) return;
if(_recentNonMessageScrollIntent()) return;
if(_messageBottomDistance()>500) _setMessageScrollToBottom();
_settleMessageScrollToBottom(false);
}
function scrollToBottom(){
_clearNewMessageScrollCue();
_scrollPinned=true;
_messageUserUnpinned=false;
// Write scrollTop once synchronously to anchor the viewport, then let
// ResizeObserver settle handle any late layout growth (Prism, KaTeX,
// Mermaid, images). Using force=false so the observer runs — force=true
// was skipping the observer and causing Firefox paint jumps when
// renderMessages({preserveScroll:true}) + scrollToBottom() fired back-to-back.
_setMessageScrollToBottom();
_settleMessageScrollToBottom(false, true);
_syncScrollToBottomCue(false,{newMessage:false});
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
if(typeof _flushDeferredActiveSessionExternalRefresh==='function') _flushDeferredActiveSessionExternalRefresh();
}
function _fmtOllamaLabel(mid){
const [namePart, ...variantParts] = mid.split(':');
const variant = variantParts.join(':');
const _fmt = (s) => {
const tokens = s.replace(/[-_]/g, ' ').split(' ');
return tokens.map(t => {
const alphaOnly = t.replace(/\./g, '');
if (t.length <= 3 && /^[a-zA-Z.]+$/.test(t)) return t.toUpperCase();
if (/^\d/.test(alphaOnly)) return t.toUpperCase();
return t.charAt(0).toUpperCase() + t.slice(1);
}).join(' ');
};
let label = _fmt(namePart);
if (variant) label += ' (' + _fmt(variant) + ')';
return label;
}
function getModelLabel(modelId){
if(!modelId) return 'Unknown';
const rawId=String(modelId||'');
// Preserve custom gateway model IDs exactly as configured.
// Examples:
// @custom:ai_gateway:Qwen3.6-35B-A3B -> Qwen3.6-35B-A3B
// @custom:qwen397b-64k -> qwen397b-64k
if(rawId.startsWith('@custom:')){
const rest=rawId.slice('@custom:'.length);
if(rest.includes(':')) return rest.slice(rest.lastIndexOf(':')+1)||rawId;
if(rest.includes('/')) return rest.slice(rest.indexOf('/')+1)||rawId;
return rest||rawId;
}
// Check dynamic labels first, then fall back to splitting the ID
if(_dynamicModelLabels[modelId]) return _dynamicModelLabels[modelId];
// Static fallback for common models
const STATIC_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-3.1-pro-preview':'Gemini 3.1 Pro','google/gemini-3-flash-preview':'Gemini 3 Flash','google/gemini-3.1-flash-lite-preview':'Gemini 3.1 Flash Lite','google/gemini-2.5-pro':'Gemini 2.5 Pro','google/gemini-2.5-flash':'Gemini 2.5 Flash','deepseek/deepseek-v4-flash':'DeepSeek V4 Flash','deepseek/deepseek-v4-pro':'DeepSeek V4 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3 (legacy)','meta-llama/llama-4-scout':'Llama 4 Scout'};
if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId];
// Safe Ollama-tag fallback: strip only the first slash-segment (provider
// prefix) so multi-slash IDs preserve their vendor hierarchy (#3360).
// URI-scheme ids (e.g. `gpt://${FOLDER}/deepseek-v4-flash/latest`, provider
// `yandex:gpt`) must NOT be first-segment-stripped — `indexOf('/')` would
// land inside the `://` and leave `/${FOLDER}/...` path junk (#3429). For a
// `scheme://authority/path...` id, drop the scheme AND the authority, then
// pick the model name from the PATH segments only. A version/channel tail
// (`latest`/`stable`/numeric) is skipped only when a real model segment
// precedes it — never promoting the authority or a container folder (#3429).
let _last;
const _uriMatch = /^[a-z][a-z0-9+.-]*:\/\/(.+)$/i.exec(modelId);
if (_uriMatch) {
const _all = _uriMatch[1].split('/').filter(Boolean);
// _all[0] is the authority (folder/host); the model lives in the path tail.
const _path = _all.slice(1);
// A pure version/channel tail: named channels, or a bare version number
// (`v4`, `1.2`, `20231231`) — NOT a mixed model name that merely starts
// with a digit (`2026-model`, `4o-mini`), which must be kept as the label.
const _isVersionTail = (s) => /^(latest|stable|current|default|v\d[\d.]*|\d[\d.]*)$/i.test(s);
const _isPlaceholder = (s) => /\$\{[^}]*\}/.test(s);
// Walk path segments right-to-left; the model name is the LAST segment that
// is neither a version/channel tail (`latest`, `v4`, `1.2`) nor a `${...}`
// env-var placeholder. Fall back to the last non-placeholder segment, then
// the literal last segment. Never returns the authority (`_all[0]`).
let _pick = '';
let _lastUsable = '';
for (let _i = _path.length - 1; _i >= 0; _i--) {
const _seg = _path[_i];
if (_isPlaceholder(_seg)) continue;
if (!_lastUsable) _lastUsable = _seg;
if (!_isVersionTail(_seg)) { _pick = _seg; break; }
}
// Fallbacks: the chosen non-version segment, else the last non-placeholder
// path segment. NEVER the authority and NEVER a `${...}` placeholder — for
// a degenerate id (`gpt://folder123`, `gpt://folder123/${MODEL}`) fall all
// the way back to the raw id rather than leak the folder/host or env var.
const _lastPath = _path[_path.length - 1] || '';
_last = _pick || _lastUsable || (_lastPath && !_isPlaceholder(_lastPath) ? _lastPath : '') || modelId;
} else {
_last = modelId.includes('/') ? (modelId.slice(modelId.indexOf('/')+1) || modelId) : modelId;
}
// Strip @provider: prefix if present (e.g. @ollama-cloud:kimi-k2.6)
if (_last.startsWith('@') && _last.includes(':')) _last = _last.split(':').slice(1).join(':');
const looksLikeOllamaTag = /^[a-z0-9][\w.-]*:[\w.-]+$/i.test(_last);
const atProvider=(rawId.startsWith('@')&&rawId.includes(':'))
? rawId.slice(1,rawId.indexOf(':')).toLowerCase()
: '';
const allowOllamaFormat=!atProvider||atProvider.startsWith('ollama');
// Narrow: only apply Ollama formatter to IDs with explicit @ollama prefix or colon-tag format.
// Avoids reformatting bare provider model IDs like claude-sonnet-4-6 or gpt-4o.
const looksLikeBareOllamaId = modelId.startsWith('@ollama') || looksLikeOllamaTag;
const ollamaLabel = _fmtOllamaLabel(_last);
if (allowOllamaFormat && (modelId.startsWith('ollama/') || modelId.startsWith('@ollama') || looksLikeOllamaTag || looksLikeBareOllamaId) && ollamaLabel !== _last) {
return ollamaLabel;
}
return _last || 'Unknown';
}
function _gatewayProviderName(provider){
const text=String(provider||'').trim();
if(!text)return'';
return text.replace(/^custom:/,'').replace(/[-_]/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
}
function _gatewayRoutingLabel(routing){
if(!routing)return'';
const provider=_gatewayProviderName(routing.used_provider||routing.provider);
return provider?`via ${provider}`:'';
}
function _formatGatewayModelLabel(modelId,labelText,routing){
if(!routing)return'';
const usedModel=String(routing.used_model||'').trim();
const base=usedModel?getModelLabel(usedModel):(labelText||getModelLabel(modelId));
const via=_gatewayRoutingLabel(routing);
return via?`${base} ${via}`:base;
}
function _gatewayRoutingFailoverText(routing){
if(!routing||!routing.has_failover)return'';
const attempts=Array.isArray(routing.routing)?routing.routing:[];
const providers=attempts.map(a=>_gatewayProviderName(a&&a.provider)).filter(Boolean);
const unique=[];providers.forEach(p=>{if(!unique.includes(p))unique.push(p);});
if(unique.length>=2)return`Failover: ${unique[0]} → ${unique[unique.length-1]}`;
const from=_gatewayProviderName(routing.requested_provider);
const to=_gatewayProviderName(routing.used_provider);
if(from&&to&&from!==to)return`Failover: ${from} → ${to}`;
return'Gateway failover detected';
}
function _gatewayModelWarningText(routing){
if(!routing||!routing.model_changed)return'';
const requested=getModelLabel(routing.requested_model||'requested model');
const used=getModelLabel(routing.used_model||'served model');
return`Model switched: ${requested} → ${used}`;
}
function _latestGatewayRoutingForSession(session){
if(!session)return null;
if(session.gateway_routing)return session.gateway_routing;
const history=Array.isArray(session.gateway_routing_history)?session.gateway_routing_history:[];
return history.length?history[history.length-1]:null;
}
function _stripXmlToolCallsDisplay(s){
// Strip ... blocks emitted by DeepSeek and
// similar models in their raw response text. These are processed separately
// as tool calls; leaving them in the content causes them to render visibly
// in the settled chat bubble. (#702)
// Also handles DSML-prefixed variants from DeepSeek/Bedrock, including
// spacing variants like "<|DSML |function_calls" and truncated prefixes.
if(!s) return s;
const lo=String(s).toLowerCase();
if(lo.indexOf('function_calls')===-1 && lo.indexOf('dsml')===-1) return s;
// Support both plain and DSML-prefixed variants.
s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls>[\s\S]*?<\/(?:\s*|\s*DSML\s*[||]\s*)?function_calls>/gi,'');
// Also remove truncated opening tags (missing closing ">" at stream tail).
s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls(?:>|$)[\s\S]*$/i,'');
// Remove malformed DSML tag fragments like "<|DSML |" that can leak in tokens.
s=s.replace(/<\s*|\s*DSML\s*[||]\s*/gi,'');
return s.trim();
}
function _sanitizeThinkingDisplayText(text){
const stripped=_stripXmlToolCallsDisplay(String(text||''));
return stripped.trim();
}
function _normalizeThinkingEchoCompare(text){
return String(text||'').replace(/\s+/g,' ').trim();
}
function _stripVisibleAssistantEchoFromThinking(thinkingText, ...visibleTexts){
const clean=_sanitizeThinkingDisplayText(thinkingText);
const thinkingNorm=_normalizeThinkingEchoCompare(clean);
if(!thinkingNorm) return '';
for(const visibleText of visibleTexts){
const visibleNorm=_normalizeThinkingEchoCompare(visibleText);
if(visibleNorm&&visibleNorm===thinkingNorm) return '';
}
return clean;
}
function renderMd(raw){
let s=(raw||'').replace(/\r\n/g,'\n').replace(/\r/g,'\n');
// ── Entity decode: must run FIRST so > lines become > for the blockquote
// pre-pass below. LLMs sometimes emit HTML-entity-encoded output; without this
// a blockquote sent as "> text" would never be recognised as a blockquote.
s=s.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,"'");
// ── Blockquote pre-pass (must run BEFORE every other markdown pass) ────────
// Group consecutive >-prefixed lines, strip the > prefix from each line,
// recursively render the stripped content with the full pipeline, and
// replace the group with a stash token. This is the only way fenced code,
// headings, hr, and ordered lists inside a blockquote can render correctly:
// the per-line passes downstream don't know about > prefixes, and by the
// time the blockquote handler used to run those passes had already mangled
// the >-prefixed lines.
//
// Walks lines (instead of using a single regex) so >-prefixed lines that
// sit inside a non-blockquote fenced block (e.g. a shell prompt in a
// ```bash``` example) are not miscaptured as a blockquote.
const _bq_stash=[];
s=(function _applyBlockquotes(input){
const lines=input.split('\n');
const out=[];
let inFence=false; // inside a non-blockquote backtick fence
let fenceLen=0;
let bqStart=-1;
const flush=(end)=>{
if(bqStart<0) return;
// Strip "> " prefix (and bare ">" → empty) from each line
const stripped=lines.slice(bqStart,end).map(l=>l.replace(/^> ?/,'')).join('\n');
// Recursive call: full pipeline on stripped content. Handles fenced
// code, headings, hr, ordered/unordered lists, nested blockquotes
// (>>) — anything that renderMd handles at the top level.
const rendered=renderMd(stripped);
_bq_stash.push(''+rendered+' ');
// Surround the token with blank lines so the paragraph splitter
// isolates it as its own chunk (otherwise the token gets wrapped
// in ... with adjacent text, producing invalid HTML).
out.push('');
out.push('\x00Q'+(_bq_stash.length-1)+'\x00');
out.push('');
bqStart=-1;
};
for(let i=0;i/.test(line)){
if(bqStart<0) bqStart=i;
} else {
flush(i);
out.push(line);
}
}
flush(lines.length);
return out.join('\n');
})(s);
// ── MEDIA: token stash (must run first, before any other processing) ───────
// Detect MEDIA: tokens emitted by the agent (e.g. screenshots,
// generated images) and replace them with inline or download links.
// Stashed so the path/URL is never processed as markdown.
const media_stash=[];
s=s.replace(/MEDIA:([^\s\)\]]+)/g,(_,raw_ref)=>{
media_stash.push(raw_ref);
return '\x00D'+(media_stash.length-1)+'\x00';
});
// ── End MEDIA stash ─────────────────────────────────────────────────────────
// Pre-pass: decode HTML entities first so markdown processing works correctly.
// This prevents double-escaping when LLM outputs entities like < > &
const decode=s=>s.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,"'");
s=decode(s);
// Pre-pass: convert safe inline HTML tags the model may emit into their
// markdown equivalents so the pipeline can render them correctly.
// Only runs OUTSIDE fenced code blocks and backtick spans (stash + restore).
// Unsafe tags (anything not in the allowlist) are left as-is and will be
// HTML-escaped by esc() when they reach an innerHTML assignment -- no XSS risk.
// Fence stash: protect code blocks and backtick spans from all further processing.
// Must run BEFORE math_stash so $..$ inside code spans is not extracted as math.
// Split into fenced blocks (\x00P — kept stashed until after all markdown passes)
// and inline backtick spans (\x00F — restored before bold/italic so **`code`** works).
// Fenced blocks are converted to here so their content is HTML-escaped
// and never exposed to list/heading/table regexes that could corrupt the layout.
// Fixes #1154: diff/patch lines inside fenced blocks (e.g. + added, - removed)
// were matching the unordered-list regex and injecting / inside ,
// breaking closure and corrupting all subsequent message rendering.
const _preBlock_stash=[];
const fence_stash=[];
// CommonMark §4.5: opening fence must start a line (with up to 3 spaces of indent)
// and closing fence must start a line with the same backtick char and at least
// as many backticks as the opener. Without line/fence-length anchoring, a literal
// ``` inside a code block (e.g. a nested markdown example) terminates the outer
// block at the wrong place, leaking content into the markdown stream where
// bold/italic/inline-code passes corrupt it. Fixes #1438 and #1696.
s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{
const langInfo=(info||'').trim();
const langMatch=langInfo.match(/^(\w[\w+-]*)$/);
const lang=langMatch?(langMatch[1]||'').trim().toLowerCase():'';
code=code||'';
const codeLines=code.split('\n');
const firstCodeLine=codeLines.find(line=>line.trim())||'';
const firstMermaidLine=codeLines.map(line=>line.trim()).find(line=>line&&!line.startsWith('%%'))||'';
const looksLikeLineNumberedToolOutput=/^\s*\d+\|/.test(firstCodeLine);
const looksLikeMermaidStart=firstMermaidLine==='---'||/^(graph|flowchart|sequenceDiagram|classDiagram|classDiagram-v2|stateDiagram|stateDiagram-v2|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|quadrantChart|requirementDiagram|C4Context|C4Container|C4Component|C4Dynamic|c4Context|c4Container|c4Component|c4Dynamic|sankey-beta|block-beta|packet-beta|xychart-beta|kanban|architecture-beta)\b/.test(firstMermaidLine);
if(lang==='mermaid'&&!looksLikeLineNumberedToolOutput&&looksLikeMermaidStart){
const id='mermaid-'+Math.random().toString(36).slice(2,10);
_preBlock_stash.push(`${esc(code.trim())}
`);
} else {
const h=lang?``:'';
const langAttr=lang?` class="language-${esc(lang)}"`:'';
const preClass=/^(md|markdown|mdx)$/.test(lang)?' class="md-source-block"':'';
// For diff/patch blocks, wrap each line in a colored span
if(lang==='diff'||lang==='patch'){
const colored=esc(code.replace(/\n$/,'')).split('\n').map(line=>{
if(line.startsWith('@@')) return `${line} `;
if(line.startsWith('+')) return `${line} `;
if(line.startsWith('-')) return `${line} `;
return `${line} `;
}).join('\n');
_preBlock_stash.push(`${h}${colored} `);
// For JSON/YAML blocks, add tree-view placeholder with raw data
} else if(lang==='json'||lang==='yaml'){
const rawCode=esc(code.replace(/\n$/,''));
// Encode newlines as
to prevent HTML attribute normalization
// (browsers collapse \n to spaces inside attribute values).
const rawAttr=rawCode.replace(/"/g,'"').replace(/\n/g,'
');
const blockId='tree-'+Math.random().toString(36).slice(2,10);
_preBlock_stash.push(``);
// CSV blocks → render as styled table
} else if(lang==='csv'){
const rows=code.replace(/\n$/,'').split('\n').filter(r=>r.trim());
if(rows.length>=2){
const headers=rows[0].split(',').map(c=>c.trim());
const body=rows.slice(1).map(r=>''+r.split(',').map(c=>`${esc(c.trim())} `).join('')+' ').join('');
_preBlock_stash.push(`${h}${headers.map(h=>`${esc(h)} `).join('')} ${body}
`);
} else {
_preBlock_stash.push(`${h}${esc(code.replace(/\n$/,''))} `);
}
} else {
_preBlock_stash.push(`${h}${esc(code.replace(/\n$/,''))} `);
}
}
return lead+'\x00P'+(_preBlock_stash.length-1)+'\x00';
});
s=s.replace(/`([^`\n]+)`/g,(_,c)=>{fence_stash.push(''+esc(c)+'');return '\x00F'+(fence_stash.length-1)+'\x00';});
// Math stash: protect $$..$$ and $..$ from markdown processing
// Runs AFTER fence_stash so backtick code spans protect their dollar-sign contents
const math_stash=[];
// Display math: $$...$$ and \[...\] (must come before inline to avoid mis-parsing)
s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
// Match a single literal backslash before the display delimiter (the common LLM form).
s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
// Inline math: $...$ — require non-space/non-digit at opening boundary to avoid
// false positives on currency like "$1,000 xuống ~$95" or "costs $5 and $10".
// Aligns with smd's se() guard which also rejects $ followed by digits.
s=s.replace(/\$([^\s$\d\n][^$\n]*?[^\s$\n]|[^\s\d])\$/g,(_,m)=>{if(m.includes(' | '))return '\$'+m+'\$';math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
// Also stash \(...\) LaTeX delimiters.
// Match a single literal backslash before the delimiter (the common LLM form).
s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
// Safe tag → markdown equivalent (these produce the same output as **text** etc.)
// Stash raw blocks so the inline rewrite below does not run
// inside them. Running that rewrite in content can introduce stray
// backticks for multiline code and break subsequent code-box rendering.
const rawPreStash=[];
s=s.replace(/(]*>[\s\S]*?<\/pre>)/gi,m=>{rawPreStash.push(m);return `\x00R${rawPreStash.length-1}\x00`;});
// Bare file:// artifact links → media. Some gateway/tool surfaces emit bare
// file:// links for local artifacts instead of MEDIA: tokens; browser clients
// cannot open the server filesystem directly, so route them through /api/media.
// Runs AFTER fenced-block (\x00P), inline-code (\x00F), AND raw- (\x00R)
// stashing so a file:// inside any code/preformatted region stays literal text
// (#3219/#3234). Only bare URLs (line-start or whitespace-delimited) match, so
// normal [label](file://...) markdown anchors keep the link path below.
s=s.replace(/(^|\s)(file:\/\/[^\s<>"')\]]+)/g,(_,lead,raw_ref)=>{
media_stash.push(raw_ref);
return lead+'\x00D'+(media_stash.length-1)+'\x00';
});
s=s.replace(/([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**');
s=s.replace(/([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**');
s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*');
s=s.replace(/([\s\S]*?)<\/i>/gi,(_,t)=>'*'+t+'*');
s=s.replace(/([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`');
s=s.replace(/ /gi,'\n');
// ── Glued-bold-heading lift (issue #1446) ────────────────────────────────
// LLMs in thinking/reasoning mode frequently emit a "section header" glued
// to the end of the previous paragraph with no whitespace, like:
//
// Para 1 text.**Heading to Para 2**
//
// Para 2 text.**Heading to Para 3**
//
// CommonMark renders that correctly as paragraph-end inline bold, but the
// visual effect is a run-on label rather than a section break. Lift the
// glued bold into its own paragraph when it follows a sentence terminator
// and is followed by a blank line.
//
// Constraints (avoid false positives):
// - Trigger only on a sentence terminator (.!?) IMMEDIATELY before `**`
// (no space) — that pattern is almost always a glued heading, not
// intentional emphasis.
// - Inner text length ≤ 80 chars — long bold runs are usually emphasis
// prose, not headings.
// - Trailing `\n\n` required — preserves mid-paragraph emphasis like
// "this is **important**." untouched.
// - Inner text must not contain newlines or `*` (single-line bold only).
// - Runs after fenced code, math, and raw are stashed, so code
// content is protected (see pipeline notes).
s=s.replace(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g,'$1\n\n**$2**\n\n');
// Inline backtick spans: restore tags produced in the stash callback above.
// Must happen BEFORE bold/italic so **`code`** → code .
s=s.replace(/\x00F(\d+)\x00/g,(_,i)=>fence_stash[+i]);
// inlineMd: process bold/italic/code/links within a single line of text.
// Used inside list items and blockquotes where the text may already contain
// HTML from the pre-pass → bold pipeline, so we cannot call esc() directly.
function inlineMd(t){
// Stash backtick code spans first so bold/italic never esc() their content
const _code_stash=[];
t=t.replace(/`([^`\n]+)`/g,(_,x)=>{_code_stash.push(`${esc(x)}`);return `\x00C${_code_stash.length-1}\x00`;});
t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`${esc(x)} `);
t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`${esc(x)} `);
t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`${esc(x)} `);
// Strikethrough: ~~text~~ → text
t=t.replace(/~~(.+?)~~/g,(_,x)=>`${esc(x)}`);
// #487: Image pass — runs while code stash is active so  inside
// backticks stays protected as a \x00C token and is never rendered as .
// Must run before _code_stash restore and before _link_stash so the image
// is not consumed by the [label](url) link regex.
t=t.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>` `);
// Stash rendered tags so autolink never matches URLs inside src=
const _img_stash=[];
t=t.replace(/( ]*>)/g,m=>{_img_stash.push(m);return `\x00G${_img_stash.length-1}\x00`;});
t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]);
// Stash [label](url) links before autolink so the URL in href= is not re-linked
const _link_stash=[];
t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|workspace:\/\/|session:\/\/|mailto:|tel:|message:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(_markdownAnchor(lb,u));return `\x00L${_link_stash.length-1}\x00`;});
t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)} ${trail}`;});
t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]);
t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]);
// Escape any plain text that isn't already wrapped in a tag we produced
// by escaping bare < > that are not part of our own tags
const SAFE_INLINE=/^<\/?(strong|em|del|code|a|img)([\s>]|$)/i;
t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag));
return t;
}
// Stash tags from the backtick pass above so the outer bold/italic
// regexes don't esc() their content (e.g. **`code`** → code )
const _ob_stash=[];
s=s.replace(/(]*>[\s\S]*?<\/code>)/g,m=>{_ob_stash.push(m);return `\x00O${_ob_stash.length-1}\x00`;});
s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`${esc(t)} `);
s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`${esc(t)} `);
s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`${esc(t)} `);
s=s.replace(/~~(.+?)~~/g,(_,t)=>`${esc(t)}`);
s=s.replace(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]);
s=s.replace(/^###### (.+)$/gm,(_,t)=>`${inlineMd(t)} `).replace(/^##### (.+)$/gm,(_,t)=>`${inlineMd(t)} `).replace(/^#### (.+)$/gm,(_,t)=>`${inlineMd(t)} `).replace(/^### (.+)$/gm,(_,t)=>`${inlineMd(t)} `).replace(/^## (.+)$/gm,(_,t)=>`${inlineMd(t)} `).replace(/^# (.+)$/gm,(_,t)=>`${inlineMd(t)} `);
s=s.replace(/^---+$/gm,' ');
// (Blockquotes are handled by the pre-pass at the top of renderMd, before
// fence_stash. The per-line passes below never see > prefixes.)
function _renderListBlock(lines, ordered){
const marker=ordered?'\\d+\\. ':'[-*+] ';
let html=ordered?'':'';
let item=null;
const flush=()=>{
if(!item) return;
const body=item.parts.join('\n').trim();
const text=body;
let inner;
if(!ordered && /^\[x\] /i.test(text)) inner='✅ '+inlineMd(text.slice(4));
else if(!ordered && /^\[ \] /.test(text)) inner='☐ '+inlineMd(text.slice(4));
else inner=inlineMd(text);
const valueAttr=item.value!==null?` value="${item.value}"`:'';
const styleAttr=item.indent?` style="margin-left:16px"`:'';
html+=`${inner} `;
item=null;
};
for(const raw of lines){
const line=String(raw||'');
const nested=line.match(new RegExp(`^ {2,}(${marker})(.*)$`));
if(nested){
flush();
item={indent:true,value:ordered?parseInt(nested[1],10):null,parts:[nested[2]]};
continue;
}
const top=line.match(new RegExp(`^(?: )?(${marker})(.*)$`));
if(top){
flush();
item={indent:false,value:ordered?parseInt(top[1],10):null,parts:[top[2]]};
continue;
}
if(!item) continue;
item.parts.push(line.replace(/^ {2,}/,'').trim());
}
flush();
return html+(ordered?' ':' ');
}
function _renderLists(src, ordered){
const lines=src.split('\n');
const out=[];
const topRe=ordered?/^(?: )?\d+\. /:/^(?: )?[-*+] /;
const nestedRe=ordered?/^ {2,}\d+\. /:/^ {2,}[-*+] /;
const contRe=/^ {2,}\S/;
let i=0;
while(i HTML that cannot satisfy the ordered-item regex.
// Keep continuation lines attached to their item and preserve explicit
// numbering via value= even when blank lines split the markdown.
s=_renderLists(s,true);
// Tables: | col | col | header row followed by | --- | --- | separator then data rows
// NOTE: table pass runs BEFORE outer link pass so [label](url) in table cells
// is handled by inlineMd() only — prevents double-linking.
s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{
const rows=block.trim().split('\n').filter(r=>r.trim());
if(rows.length<2)return block;
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
if(!isSep(rows[1]))return block;
// _protectPipes: temporarily swap pipes inside matching bracket pairs for a
// sentinel before split('|'), then restore. Iterates until no more matches
// so all pipes inside one pair are caught.
// Note: both opening and closing brace literals in the character classes
// are written as hex escapes (\x7b and \x7d) so the JS source contains no
// bare brace glyphs that would confuse the brace-counting extractFunc in
// tests/test_renderer_js_behaviour.py. Regex semantics are identical.
// Bracket set is paren / square / curly only -- NOT angle brackets, since
// angle brackets are overwhelmingly comparison operators in real LLM table
// output (`| x < 5 | y > 10 |`) and treating them as a pair collapses cells.
const _protectPipes=r=>{let prev;do{prev=r;r=r.replace(/([([\x7b][^)\]\x7d]*)[|]([^)\]\x7d]*[)\]\x7d])/g,(_,a,b)=>a+'\x00PIPE\x00'+b);r=r.replace(/(`[^`]*)[|]([^`]*`)/g,(_,a,b)=>a+'\x00PIPE\x00'+b);}while(r!==prev);return r;};
const _restorePipes=s=>s.replace(/\x00PIPE\x00/g,'|');
const parseRow=r=>{r=_protectPipes(r);return r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${inlineMd(_restorePipes(c.trim()))} `).join('');};
const parseHeader=r=>{r=_protectPipes(r);return r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${inlineMd(_restorePipes(c.trim()))} `).join('');};
const header=`${parseHeader(rows[0])} `;
const body=rows.slice(2).map(r=>`${parseRow(r)} `).join('');
// Surround with blank lines so the final paragraph splitter treats the
// generated table as its own block even when the regex consumes one of the
// markdown block's trailing newlines.
return `\n\n\n\n`;
});
// #487: Outer image pass — handles  in plain paragraphs (outside tables/lists).
// Runs AFTER the table pass (images in table cells are handled by inlineMd() above).
// Runs BEFORE the outer [label](url) link pass so the image is not consumed as a plain link.
s=s.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>` `);
// Outer link pass for labeled links in plain paragraphs (outside table cells).
// Runs AFTER the table pass so table cells are processed by inlineMd() only.
// Stash existing tags first to avoid re-linking already-linked URLs.
const _a_stash=[];
s=s.replace(/( ]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;});
s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|workspace:\/\/|session:\/\/|mailto:|tel:|message:)[^\s\)]+)\)/g,(_,label,url)=>_markdownAnchor(label,url));
s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]);
// Restore raw only after markdown rewrites so literal preformatted
// content stays placeholder-protected, then let the sanitizer normalize tags.
s=s.replace(/\x00R(\d+)\x00/g,(_,i)=>rawPreStash[+i]);
// Sanitize any remaining HTML tags. The renderer intentionally returns
// HTML and inserts it with innerHTML later, so tag names alone are not enough:
// raw/model-provided HTML like or
// must lose executable attributes and dangerous schemes while preserving the
// small set of attributes generated by this markdown pipeline.
// Reference only — documents the allowed tag set. Superseded by _tag() allowlists.
// Tests verify this list is complete; _tag() enforces it.
const SAFE_TAGS=/^<\/?(?:strong|em|del|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div|span|img)([\s>]|$)/i;
function _safeAttrValue(v){
return String(v||'').replace(/"/g,'"').replace(/'/g,"'").replace(/&/g,'&').trim();
}
function _markdownHref(raw){
const href=String(raw||'').replace(/"/g,'%22');
if(/^session:\/\//i.test(href)){
const sid=href.replace(/^session:\/\//i,'').split(/[?#]/)[0];
try{
const decoded=decodeURIComponent(sid);
if(typeof _sessionUrlForSid==='function') return _sessionUrlForSid(decoded);
return 'session/'+encodeURIComponent(decoded);
}catch(_){
return 'session/'+encodeURIComponent(sid);
}
}
if(/^workspace:\/\//i.test(href)){
try{
const rel=decodeURIComponent(href.replace(/^workspace:\/\//i,'')).replace(/^~\//,'').replace(/^\.\//,'');
return '#workspace='+encodeURIComponent(rel);
}catch(_){
return '#';
}
}
if(/^file:\/\//i.test(href)){
try{
const path=decodeURIComponent(href.replace(/^file:\/\//i,''));
return 'api/media?path='+encodeURIComponent(path)+'&inline=1';
}catch(_){
return 'api/media?path='+encodeURIComponent(href.replace(/^file:\/\//i,''))+'&inline=1';
}
}
return href;
}
function _isInternalSessionHref(raw){
const href=String(raw||'').trim();
if(/^session\/[^?#]+/i.test(href)) return true;
try{
const base=(typeof document!=='undefined'&&document.baseURI)||
(typeof window!=='undefined'&&window.location&&window.location.href)||
'http://localhost/';
const url=new URL(href,base);
const baseUrl=new URL(base,base);
if(url.origin!==baseUrl.origin) return false;
const basePath=baseUrl.pathname.replace(/(?:index\.html)?$/,'').replace(/\/[^/]*$/,'/');
const root=basePath.endsWith('/')?basePath:basePath+'/';
return url.pathname.startsWith(root+'session/')||url.pathname.startsWith('/session/');
}catch(_){
return false;
}
}
function _isSafeLabelInline(tag){
return /^<\/?(strong|em|del|code)([\s>]|$)/i.test(tag);
}
function _markdownLabelHtml(label){
const _label_stash=[];
const tokenized=String(label||'').replace(/<\/?[a-z][^>]*>/gi,tag=>{
if(!_isSafeLabelInline(tag)) return tag;
_label_stash.push(tag);
return `\x00H${_label_stash.length-1}\x00`;
});
return esc(tokenized).replace(/\x00H(\d+)\x00/g,(_,i)=>_label_stash[+i]);
}
function _markdownAnchor(label,rawUrl){
const href=_markdownHref(rawUrl);
const internal=/^session:\/\//i.test(String(rawUrl||'')) || _isInternalSessionHref(href);
return ` ${_markdownLabelHtml(label)} `;
}
function _isSafeUrl(v, img){
const raw=_safeAttrValue(v);
const compact=raw.replace(/[\u0000-\u001f\u007f\s]+/g,'').toLowerCase();
if(!compact) return false;
if(/^(javascript|data|vbscript):/i.test(compact)) return false;
if(/^https?:\/\//i.test(raw)) return true;
if(/^(mailto:|tel:|message:)/i.test(raw)) return true;
if(img && /^api\//i.test(raw)) return true;
if(!img && (/^api\//i.test(raw) || /^#/.test(raw) || _isInternalSessionHref(raw))) return true;
return false;
}
function _attrs(raw){
const out={};
String(raw||'').replace(/([a-zA-Z0-9:_-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>`]+)))?/g,(_,k,dq,sq,bare)=>{
out[String(k).toLowerCase()]=dq!==undefined?dq:(sq!==undefined?sq:(bare!==undefined?bare:''));
return '';
});
return out;
}
function _cls(v, allowed){
const got=String(v||'').split(/\s+/).filter(c=>allowed.includes(c));
return got.length?` class="${esc(got.join(' '))}"`:'';
}
function _tag(tag){
const m=String(tag||'').match(/^<\s*(\/)?\s*([a-zA-Z][\w:-]*)([\s\S]*?)(\/)?\s*>$/);
if(!m) return esc(tag);
const closing=!!m[1];
const name=m[2].toLowerCase();
const rawAttrs=m[3]||'';
const plain=['strong','em','del','pre','h1','h2','h3','h4','h5','h6','ul','ol','table','thead','tbody','tr','th','td','blockquote','p','br','hr'];
if(closing) return plain.includes(name)||['a','div','span','li','code'].includes(name)?`${name}>`:'';
if(name==='code'){
const a=_attrs(rawAttrs);
const cls=/^language-[a-z0-9_+-]+$/i.test(a.class||'')?` class="${esc(a.class)}"`:'';
return ``;
}
if(plain.includes(name)) return `<${name}>`;
const a=_attrs(rawAttrs);
if(name==='li'){
const value=/^\d+$/.test(a.value||'')?` value="${esc(a.value)}"`:'';
const style=(a.style||'').replace(/\s+/g,'').toLowerCase()==='margin-left:16px'?` style="margin-left:16px"`:'';
return ``;
}
if(name==='span'){
return ``;
}
if(name==='div'){
const cls=_cls(a.class,['pre-header','mermaid-block','katex-block']);
const mermaid=a['data-mermaid-id']?` data-mermaid-id="${esc(a['data-mermaid-id'])}"`:'';
const katex=a['data-katex']==='display'?' data-katex="display"':'';
return `