// `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; const OFFLINE_HEALTH_TIMEOUT_MS=10000; const OFFLINE_FETCH_FAILURES_BEFORE_BANNER=2; let _offlineVisible=false; let _offlineReason='browser'; let _offlineProbeTimer=null; let _offlineChecking=false; let _offlineProbePromise=null; let _offlineHealthProbePromise=null; let _offlineFetchProbeFailures=0; 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(_){}},OFFLINE_HEALTH_TIMEOUT_MS); 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,opts){ opts=opts||{}; const visibleAtStart=_offlineVisible; const requireConsecutiveFailures=opts.requireConsecutiveFailures!==false; if(visibleAtStart)_setOfflineChecking(true); const ok=await _probeOfflineRecovery(); if(visibleAtStart)_setOfflineChecking(false); if(ok){ _offlineFetchProbeFailures=0; if(_offlineVisible){_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();} return true; } if(!visibleAtStart&&requireConsecutiveFailures){ _offlineFetchProbeFailures+=1; if(_offlineFetchProbeFailures{ if(!_offlineVisible)return false; _setOfflineChecking(true); const ok=await _probeOfflineRecovery(); _setOfflineChecking(false); if(ok){_offlineFetchProbeFailures=0;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',{requireConsecutiveFailures:false});}); window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();}); if(!_browserReportsOnline())void _showOfflineBannerIfProbeFails('browser',{requireConsecutiveFailures:false}); } 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?`
${esc(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 ? `` : ''; 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 ``; } 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; let _dashboardLastNonNeverMode='auto'; // Server-scoped dashboard config keeps this restore target session-global on purpose. let _dashboardSettingsLoadSeq=0; let _dashboardSettingsWriteSeq=0; function _dashboardIsBrowserLoopback(){ const host=(window.location.hostname||'').replace(/^\[|\]$/g,'').toLowerCase(); return host==='127.0.0.1'||host==='localhost'||host==='::1'; } function _normalizeDashboardEnabledMode(mode){ return mode==='auto'||mode==='always'||mode==='never'?mode:'auto'; } function _setDashboardModeForChip(mode){ mode=_normalizeDashboardEnabledMode(mode); if(mode==='auto'||mode==='always') _dashboardLastNonNeverMode=mode; } function _getDashboardChipRestoreMode(){ return _dashboardLastNonNeverMode||'auto'; } 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=>``).join('')}
`; } function _mediaPlayerHtml(kind, src, name, extra=''){ const safeName=esc(name||'media'); const safeSrc=esc(src); const tag=kind==='video' ? `` : ``; return `
${tag}
${safeName}${extra}
${_mediaSpeedControlsHtml(kind,safeName)}
`; } function _renderAttachmentHtml(fname, url){ const kind=_mediaKindForName(fname); if(kind==='image') return `${esc(fname)}`; 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:remaining, title:provider+' — '+(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:remaining, title:provider+' — '+(status.message||'Provider quota loaded')+(parts.length?' — '+parts.join(' · '):'')}; } } return null; } function renderProviderQuotaIndicator(status){ const chip=$('providerQuotaChip'); const label=$('providerQuotaChipLabel'); const mobileAction=$('composerMobileQuotaAction'); const mobileLabel=$('composerMobileQuotaLabel'); if(!chip||!label) return; // Hide entirely when the user has disabled the ambient quota chip in Settings. // Boot defaults this on; an explicit false preference suppresses it. if(window._showQuotaChip!==true){ chip.hidden=true; label.textContent=''; chip.removeAttribute('title'); if(mobileAction){mobileAction.style.display='none';mobileAction.removeAttribute('title');} if(mobileLabel) mobileLabel.textContent=''; return; } const text=_providerQuotaIndicatorText(status); if(!text||status.status!=='available'||(!status.quota&&!status.account_limits)){ chip.hidden=true; label.textContent=''; chip.removeAttribute('title'); if(mobileAction){mobileAction.style.display='none';mobileAction.removeAttribute('title');} if(mobileLabel) mobileLabel.textContent=''; return; } label.textContent=text.label; chip.title=text.title; chip.hidden=false; if(mobileAction){mobileAction.style.display='';mobileAction.title=text.title;} if(mobileLabel) mobileLabel.textContent=text.label; } 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');} const mobileAction=$('composerMobileQuotaAction'); if(mobileAction){mobileAction.style.display='none';mobileAction.removeAttribute('title');} const mobileLabel=$('composerMobileQuotaLabel'); if(mobileLabel) mobileLabel.textContent=''; 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 (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 _mountSearchableModelSelect(opts={}){ const root=opts.root; if(!root) return null; const choices=Array.isArray(opts.choices) ? opts.choices .map(choice=>choice&&choice.id?{id:String(choice.id),label:String(choice.label||choice.id)}:null) .filter(Boolean) : []; const selectedValue=String(opts.selectedValue||''); const onModelChange=typeof opts.onModelChange==='function' ? opts.onModelChange : ()=>{}; const selectId=opts.selectId||''; const customInputId=opts.customInputId||''; const listedChoiceIds=new Set(choices.map(choice=>choice.id)); const listedSelection=listedChoiceIds.has(selectedValue) ? selectedValue : ''; const customSelection=listedSelection ? '' : selectedValue; let lastListedValue=listedSelection||(choices[0]?choices[0].id:''); root.innerHTML= `
`+ ``+ ``+ `
`+ ``+ `
${esc(t('model_custom_label')||'Custom model ID')}
`+ `
`+ ``+ ``+ `
`; const searchInput=root.querySelector('.model-search-input'); const clearButton=root.querySelector('.model-search-clear'); const selectEl=selectId ? root.querySelector(`#${selectId}`) : root.querySelector('select'); const customInput=customInputId ? root.querySelector(`#${customInputId}`) : root.querySelector('.model-custom-input'); const customButton=root.querySelector('.model-custom-btn'); if(!searchInput||!clearButton||!selectEl||!customInput||!customButton) return null; const noMatchesOption=document.createElement('option'); noMatchesOption.value=''; noMatchesOption.textContent='No matching models'; noMatchesOption.disabled=true; noMatchesOption.hidden=true; selectEl.appendChild(noMatchesOption); for(const choice of choices){ const option=document.createElement('option'); option.value=choice.id; option.textContent=choice.label; selectEl.appendChild(option); } if(listedSelection){ selectEl.value=listedSelection; }else if(customSelection){ selectEl.selectedIndex=-1; }else if(choices.length){ selectEl.value=choices[0].id; onModelChange(lastListedValue); } customInput.value=customSelection; const applyFilter=()=>{ const needle=(searchInput.value||'').trim().toLowerCase(); let visibleCount=0; for(const option of Array.from(selectEl.options)){ if(option===noMatchesOption) continue; const haystack=`${option.textContent||''} ${option.value||''}`.toLowerCase(); const visible=!needle||haystack.includes(needle); option.hidden=!visible; if(visible) visibleCount++; } noMatchesOption.hidden=visibleCount!==0; }; const applyCustomSelection=()=>{ onModelChange((customInput.value||'').trim()); }; searchInput.addEventListener('input', applyFilter); clearButton.addEventListener('click', ()=>{ searchInput.value=''; applyFilter(); searchInput.focus(); }); selectEl.addEventListener('change', ()=>{ customInput.value=''; lastListedValue=selectEl.value||lastListedValue; onModelChange(lastListedValue); }); customInput.addEventListener('input', ()=>{ const value=(customInput.value||'').trim(); if(value){ selectEl.selectedIndex=-1; onModelChange(value); return; } customInput.value=''; if(lastListedValue){ selectEl.value=lastListedValue; onModelChange(lastListedValue); return; } onModelChange(''); }); customInput.addEventListener('keydown', (event)=>{ if(event.key!=='Enter') return; event.preventDefault(); applyCustomSelection(); }); customButton.addEventListener('click', (event)=>{ event.preventDefault(); applyCustomSelection(); }); applyFilter(); return {searchInput,selectEl,customInput,customButton}; } 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=``; 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=``; 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 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(); } }); // ── Fit-based composer footer collapse ────────────────────────────────────── // Stage classes on .composer-footer: // (none) full labels · .cf-icons icon chips · .cf-icons.cf-burger hamburger. let _composerFitScheduled=false; let _composerFitResizeObserver=null; let _composerFitMutationObserver=null; let _composerFitObservedFooter=null; let _composerFitResizeListenerBound=false; function _fitComposerFooter(){ const footer=document.querySelector('.composer-footer'); if(!footer) return; const left=footer.querySelector('.composer-left'); if(!left) return; if(!left.clientWidth) return; const overflows=function(){return left.scrollWidth>left.clientWidth+1;}; footer.classList.remove('cf-icons','cf-burger'); if(!overflows()) return; footer.classList.add('cf-icons'); if(!overflows()) return; footer.classList.add('cf-burger'); } window._fitComposerFooter=_fitComposerFooter; function _scheduleComposerFit(){ if(_composerFitScheduled) return; _composerFitScheduled=true; requestAnimationFrame(function(){ _composerFitScheduled=false; try{_fitComposerFooter();}catch(_){ } }); } window._scheduleComposerFit=_scheduleComposerFit; function _initComposerFooterFit(){ const footer=document.querySelector('.composer-footer'); const left=footer&&footer.querySelector('.composer-left'); if(!footer||!left) return; _scheduleComposerFit(); if(_composerFitObservedFooter===footer) return; if(_composerFitResizeObserver){try{_composerFitResizeObserver.disconnect();}catch(_){ }} if(_composerFitMutationObserver){try{_composerFitMutationObserver.disconnect();}catch(_){ }} _composerFitResizeObserver=null; _composerFitMutationObserver=null; _composerFitObservedFooter=footer; if(window.ResizeObserver){ try{ _composerFitResizeObserver=new ResizeObserver(_scheduleComposerFit); _composerFitResizeObserver.observe(footer); // Also observe the left control group directly: the footer's outer width // may not change when right-side controls (status/context chips) appear or // resize, but that shrinks .composer-left's available room and must // retrigger a refit. (Codex gate #4657.) if(left && left!==footer){try{_composerFitResizeObserver.observe(left);}catch(_){ }} }catch(_){ } } if(window.MutationObserver){ try{ _composerFitMutationObserver=new MutationObserver(_scheduleComposerFit); _composerFitMutationObserver.observe(left,{ childList:true,subtree:true,characterData:true, attributes:true,attributeFilter:['class','style','hidden'] }); }catch(_){ } } if(!_composerFitResizeListenerBound){ window.addEventListener('resize',_scheduleComposerFit); _composerFitResizeListenerBound=true; } } window._initComposerFooterFit=_initComposerFooterFit; if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded',_initComposerFooterFit); }else{ _initComposerFooterFit(); } // ── 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'; if(effort==='minimal') return 'Minimal'; if(effort==='low') return 'Low'; if(effort==='medium') return 'Medium'; if(effort==='high') return 'High'; if(effort==='xhigh') return 'XHigh'; return effort.charAt(0).toUpperCase()+effort.slice(1); } 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); const labelText='Reasoning effort: '+text; chip.title=labelText; chip.setAttribute('aria-label',labelText); } 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 openMobileComposerConfig(){ const panel=$('composerMobileConfigPanel'); if(!panel) return; if(typeof closeProfileDropdown==='function') closeProfileDropdown(); if(typeof closeWsDropdown==='function') closeWsDropdown(); closeModelDropdown(); closeReasoningDropdown(); if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown(); panel.classList.add('open'); _syncMobileComposerConfigButton(true); } 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; } openMobileComposerConfig(); } function openComposerContextMenu(e){ if(e){ e.preventDefault(); e.stopPropagation(); } const tooltip=$('ctxTooltip'); if(tooltip){ tooltip.classList.remove('ctx-tooltip-active'); tooltip.setAttribute('aria-hidden','true'); } openMobileComposerConfig(); } window.openComposerContextMenu=openComposerContextMenu; 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; // #4970 review: track recent LOW-DELTA upward message-pane wheel intent separately from // the decisive deltaY<-30 sticky-unpin threshold. A gentle trackpad wheel // (deltaY:-5) is real user intent but never crosses -30, so without this the // post-render artifact suppression would swallow it for the whole window. const MESSAGE_WHEEL_INTENT_SUPPRESS_MS=1200; let _lastMessageWheelIntentMs=-Infinity; // #4970 review (greptile P1): keyboard scrolling of the message pane (PageUp/Down, // arrows, Space, Home/End) fires a native `scroll` event with NO wheel/touch/ // scrollbar/non-message intent. Without recording it, a keyboard scroll-up inside // the post-render artifact window is swallowed and live-follow snaps the reader // back to the bottom. Stamp a generic scroll-key intent so the suppression skips it. const MESSAGE_KEY_SCROLL_INTENT_SUPPRESS_MS=1200; let _lastMessageKeyScrollIntentMs=-Infinity; let _newMessageCueVisible=false; let _lastMessageRenderAt=-Infinity; function _recentMessageRenderArtifactWindow(ms){ return performance.now()-_lastMessageRenderAt<(ms||1400); } 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(); return; } // #4970: record ANY upward message-pane wheel motion as recent wheel intent, // including gentle low-delta trackpad wheels (e.g. deltaY:-5) that never reach // the decisive -30 sticky-unpin threshold below. The post-render artifact // suppression consults _recentMessageWheelIntent() so it cannot swallow a real // gentle scroll-up. This does NOT unpin on its own — only the <-30 branch and // the scroll listener's movedUp branch flip _messageUserUnpinned. if(typeof e.deltaY==='number'&&e.deltaY<0) _lastMessageWheelIntentMs=performance.now(); 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; // #4970 review: also clear low-delta wheel intent on session switch, else a // gentle wheel in the previous chat leaves _recentMessageWheelIntent() true // into the new chat's first post-render window — the artifact then isn't // suppressed, falls into movedUp, and falsely unpins live-follow. _lastMessageWheelIntentMs=-Infinity; // #4970 review (greptile P1): same hygiene for keyboard scroll intent. _lastMessageKeyScrollIntentMs=-Infinity; clearTimeout(_deferredOlderMessagesTimer); _deferredOlderMessagesTimer=0; } function _resetStreamScrollFollow(){ _clearNewMessageScrollCue(); _messageUserUnpinned=false; _scrollPinned=true; _nearBottomCount=0; _lastScrollTop=null; // #4970 review: clear low-delta wheel intent on fresh stream start too, else a // gentle upward wheel within the prior 1200ms can under-suppress a genuine // no-intent render artifact and silently disable live follow for the new stream. _lastMessageWheelIntentMs=-Infinity; // #4970 review (greptile P1): same hygiene for keyboard scroll intent. _lastMessageKeyScrollIntentMs=-Infinity; _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}); // #4970 review (greptile P1): record keyboard-driven message-pane scrolling as // user intent. PageUp/PageDown, Arrow keys, Space/Shift+Space, Home/End scroll // the pane and fire a native scroll event with no wheel/touch intent — without // this stamp a keyboard scroll-up inside the post-render artifact window is // swallowed and live-follow snaps the reader back to the bottom. Only count it // when the scroll container (or a descendant) is the active/scrolling target, // not when typing in the composer or activating an in-transcript control. const _MESSAGE_SCROLL_KEYS=new Set([ 'PageUp','PageDown','ArrowUp','ArrowDown','Home','End','Spacebar',' ', ]); const _isMessageInteractiveKeyTarget=(node)=>{ if(!node||!el.contains(node)) return false; if(node.tagName==='INPUT'||node.tagName==='TEXTAREA'||node.isContentEditable) return true; return !!(node.closest&&node.closest('button,a[href],select,summary,[role="button"],[role="tab"],[role="menuitem"],[contenteditable="true"]')); }; document.addEventListener('keydown',(e)=>{ if(!e||!_MESSAGE_SCROLL_KEYS.has(e.key)) return; const a=document.activeElement; const t=e.target; // Ignore keys aimed at editable fields (composer, inputs, contenteditable). if(a&&(a.tagName==='INPUT'||a.tagName==='TEXTAREA'||a.isContentEditable)) return; // Space/Spacebar activates focused transcript controls (buttons, role=button, // links, tabs) rather than scrolling. The listener is capture-phase, so target // handlers have not yet preventDefault()/stopPropagation()'d; inspect the // active/target control path directly. if((e.key===' '||e.key==='Spacebar')&&(_isMessageInteractiveKeyTarget(t)||_isMessageInteractiveKeyTarget(a))) return; // Count only when the message pane itself is the scroll target: it is focused, // contains the focus, or the pointer is over it (keyboard scroll w/o focus). if(a===el||el.contains(a)||el.matches(':hover')){ _lastMessageKeyScrollIntentMs=performance.now(); } },{capture:true,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; // Suppress the post-render scroll artifact: right after renderMessages() // rebuilds #msgInner, the browser can emit a non-user upward scroll event. // The typeof guards keep this branch inert in unit harnesses that inject // the listener body without these helpers (and short-circuit before any // call), while production evaluates the real intent/recency helpers. // #4970: also require no recent low-delta message-pane wheel intent, so a // gentle trackpad scroll-up (deltaY>-30) right after a render still unpins // instead of being swallowed for the artifact window. // #4970 review: and never suppress while a scrollbar drag is active — a // manual scrollbar-drag upward scroll inside the window is real intent. // typeof guard keeps the #4295 node harness (no _scrollbarDragActive // injected) inert via short-circuit. // #4970 review (greptile P1): likewise skip suppression when the reader // recently scrolled the pane with the keyboard — a keyboard scroll-up is // real intent that produces a native scroll event with no wheel/touch. if(movedUp && typeof _recentMessageRenderArtifactWindow==='function' && typeof _recentMessageTouchScrollIntent==='function' && typeof _recentNonMessageScrollIntent==='function' && typeof _recentMessageWheelIntent==='function' && typeof _recentMessageKeyScrollIntent==='function' && (typeof _scrollbarDragActive==='undefined' || !_scrollbarDragActive) && _recentMessageRenderArtifactWindow(1400) && !_recentMessageTouchScrollIntent() && !_recentNonMessageScrollIntent() && !_recentMessageWheelIntent() && !_recentMessageKeyScrollIntent()){ _lastScrollTop=top; return; } _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, quota, 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) ── // Hover/focus still exposes the compact tooltip, but a click/tap now opens the // shared composer config menu used by the phone footer so the richer context // details and compress action have one interaction path. 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',openComposerContextMenu); // 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 ?_compactComposerModelChipLabel(usedModel,getModelLabel(usedModel)) :_compactComposerModelChipLabel(modelId,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?`
    ${esc(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(`
    ${h}
    ${rawCode}
    `); // 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=>``).join('')}${body}
    ${esc(h)}
    `); } 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 ![x](url) 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)=>`${esc(alt)}`);
    // 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(/([^<]*)[|]([^<]*<\/code>)/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${header}${body}
\n\n`; }); // #487: Outer image pass — handles ![alt](url) 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)=>`${esc(alt)}`); // 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)?``:'';
    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 ``;
    }
    if(name==='a'){
      if(!_isSafeUrl(a.href,false)) return '';
      const target=a.target==='_blank'?' target="_blank"':'';
      const rel=a.rel==='noopener'?' rel="noopener"':'';
      const cls=_cls(a.class,['msg-media-link','skill-linked-file','skill-file-back','session-link']);
      const download=a.download?` download="${esc(a.download)}"`:'';
      return ``;
    }
    if(name==='img'){
      if(!_isSafeUrl(a.src,true)) return '';
      const cls=_cls(a.class,['msg-media-img']);
      const alt=` alt="${esc(_safeAttrValue(a.alt||''))}"`;
      const loading=a.loading==='lazy'?' loading="lazy"':'';
      return ``;
    }
    return '';
  }
  s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>_tag(tag));
  // Incomplete raw tags must not survive until paragraph wrapping, where the
  // renderer's generated 

could provide a closing ">" and turn them into // executable HTML in innerHTML (for example: \n]*$/gm,tag=>esc(tag)); // Autolink: convert plain URLs to clickable links. // Stash
, and
 blocks so autolink never runs inside them.
  const _al_stash=[];
  s=s.replace(/(]*>[\s\S]*?<\/a>|]*>|]*>[\s\S]*?<\/pre>)/g,m=>{_al_stash.push(m);return `\x00B${_al_stash.length-1}\x00`;});
  s=s.replace(/(https?:\/\/[^\s<>"'\)\]]+)/g,(url)=>{
    // Strip trailing punctuation that was likely not part of the URL
    const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';
    const clean=trail?url.slice(0,-1):url;
    return `${esc(clean)}${trail}`;
  });
  s=s.replace(/\x00B(\d+)\x00/g,(_,i)=>_al_stash[+i]);
  // Restore math stash → katex placeholder spans/divs
  // These will be rendered by renderKatexBlocks() after DOM insertion
  s=s.replace(/\x00M(\d+)\x00/g,(_,i)=>{
    const item=math_stash[+i];
    if(item.type==='display'){
      return `
${esc(item.src)}
`; } return `${esc(item.src)}`; }); // Restore fenced block stash (\x00P) →
 HTML.
  // Happens AFTER all markdown passes (lists, headings, tables, etc.) so
  // diff/patch content inside code blocks is never misinterpreted as markdown.
  // The _pre_stash below then protects these blocks from paragraph splitting.
  s=s.replace(/\x00P(\d+)\x00/g,(_,i)=>_preBlock_stash[+i]);
  // Stash rendered 
 blocks (with optional pre-header div) and mermaid/katex
  // divs before paragraph splitting so \n inside code blocks is never replaced
  // with 
. Token \x00E (next free after B D F G L M C O A). // Fixes #745: code blocks collapse to single line when not preceded by blank line. const _pre_stash=[]; // #1463 / #1618: regex must match
 with ANY attributes — PR #484 added
  // 
 for JSON/YAML and 
 for
  // diff/patch which the literal-
 shape missed. Newlines inside those
  // blocks were falling through to the paragraph wrap below and getting
  // converted to 
, causing the YAML/JSON/diff collapse. PR #1516's CSS // fix targeted the wrong layer (Prism token white-space) — by the time it // ran, the \n had already been replaced. The CSS rule is kept as defense // in depth. s=s.replace(/(
[\s\S]*?<\/div>)?]*>[\s\S]*?<\/pre>|
/g,m=>{ _pre_stash.push(m); return '\x00E'+(_pre_stash.length-1)+'\x00'; }); const parts=s.split(/\n{2,}/); s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|table|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `

${p.replace(/\n/g,'
')}

`;}).join('\n'); s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]); // ── Restore MEDIA stash → inline images or download links ───────────────── s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{ let ref=media_stash[+i]; // Keep this logic self-contained: some tests extract renderMd() alone and // execute it in node, without the top-level helper functions from ui.js. const mediaKindForName=(name='')=>{ const clean=String(name||'').split('?')[0].toLowerCase(); if(/\.(mp3|wav|m4a|aac|ogg|oga|opus|flac)$/i.test(clean)) return 'audio'; if(/\.(mp4|mov|m4v|webm|ogv|avi|mkv)$/i.test(clean)) return 'video'; if(_IMAGE_EXTS.test(clean)) return 'image'; return ''; }; const mediaPlayerHtml=(kind,src,name)=>{ if(typeof _mediaPlayerHtml==='function') return _mediaPlayerHtml(kind,src,name); const safeName=esc(name||kind||'media'); const safeSrc=esc(src); const tag=kind==='video' ? `` : ``; return `
${tag}
${safeName}
`; }; const localArtifactCard=(src,name)=>{ const safeSrc=esc(src); const safeName=esc(name||'image'); const tt=(typeof t==='function')?t:(key=>({media_download:'Download'}[key]||key)); // Clean inline image (keeps the existing .msg-media-img lightbox-on-click // behavior) with a hover/focus-revealed Download action overlaid top-right, // matching the ChatGPT/Claude/Gemini pattern. The image stays the hero — // no permanent card chrome. Download is the one affordance the lightbox // (zoom-on-click) doesn't already provide. const dlLabel=esc(tt('media_download')); const dlSvg=''; return `${safeName}${dlSvg}`; }; if(/^file:\/\//i.test(ref)){ try{ const u=new URL(ref); ref=decodeURIComponent(u.pathname||ref.replace(/^file:\/\//i,'')); }catch(_){ try{ref=decodeURIComponent(ref.replace(/^file:\/\//i,''));} catch(__){ref=ref.replace(/^file:\/\//i,'');} } } // HTTP(S) URL if(/^https?:\/\//i.test(ref)){ // Rewrite localhost/127.0.0.1 to the actual server base URL so remote // users (VPN, Docker, deployed) can load agent-generated images (#642). // Strip the trailing slash from document.baseURI so the URL's own path // joins cleanly — this preserves any subpath mount (e.g. /hermes/). let src=ref; if(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(src)){ const base=(document.baseURI||'').replace(/\/$/,''); src=src.replace(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i,base); } // MEDIA: tokens are usually tool-generated images. Render all https:// // URLs as so extensionless CDN paths still work (#853), while // preserving explicit audio/video/SVG URLs with their proper handlers. const urlPath=src.split('?')[0]; const mediaKind=mediaKindForName(urlPath); // SVG URLs → render inline as image if(_SVG_EXTS.test(urlPath)){ return `${t('media_svg_label')}`; } if(mediaKind==='audio'||mediaKind==='video') return mediaPlayerHtml(mediaKind,src,urlPath.split('/').pop()||mediaKind); // Render all https:// URLs as — extensionless CDN paths like fal.media still work (#853) if(_IMAGE_EXTS.test(urlPath) || /^https?:\/\//i.test(src)){ return `image`; } return `${esc(src)}`; } // Local file path const mediaSessionId=(typeof S!=='undefined'&&S&&S.session&&S.session.session_id)?String(S.session.session_id):''; const apiUrl='api/media?path='+encodeURIComponent(ref)+(mediaSessionId?'&session_id='+encodeURIComponent(mediaSessionId):''); const localKind=mediaKindForName(ref); if(localKind==='image'){ return localArtifactCard(apiUrl,ref.split('/').pop()||'image'); } // SVG → inline image (no download, render directly) if(_SVG_EXTS.test(ref)){ return `${t('media_svg_label')}`; } // Audio/video → inline player with speed controls; use &inline=1 for byte-range seeking if(_AUDIO_EXTS.test(ref)||_VIDEO_EXTS.test(ref)){ const kind=_AUDIO_EXTS.test(ref)?'audio':'video'; return _mediaPlayerHtml(kind,apiUrl+'&inline=1',ref.split('/').pop()||ref); } // PDF files → render first page preview with lazy-load if(_PDF_EXTS.test(ref)){ const fname=esc(ref.split('/').pop()||ref); return `
⏳ ${t('pdf_loading')} ${fname}...
`; } // HTML files → render inline in sandboxed iframe with lazy-load if(_HTML_EXTS.test(ref)){ return `
⏳ ${t('html_loading')}
`; } // .patch/.diff files → render inline as colored diff instead of download const fname=esc(ref.split('/').pop()||ref); if(/\.(patch|diff)$/i.test(ref)){ return `
${t('diff_loading')} ${fname}...
`; } // CSV files → lazy-load and render as table if(_CSV_EXTS.test(ref)){ return `
${t('csv_loading')} ${fname}...
`; } // Excalidraw files → lazy-load inline embed if(_EXCALIDRAW_EXTS.test(ref)){ return `
${t('excalidraw_loading')} ${fname}...
`; } return `📎 ${fname}`; }); // ── End MEDIA restore ────────────────────────────────────────────────────── // Restore blockquote stash. Done last so the inner HTML (already produced // by the recursive renderMd in the pre-pass) is dropped into the final // string verbatim — no further passes can mangle it. s=s.replace(/\x00Q(\d+)\x00/g,(_,i)=>_bq_stash[+i]); return s; } function _stripAttachedFilesMarkerForDisplay(text){ return String(text||'').replace(/\n\n\[Attached files: [^\]]+\]$/,'').trim(); } function setStatus(t){ if(!t)return; showToast(t, 4000); } function setComposerStatus(t){ const el=$('composerStatus'); if(!el)return; const statusHidden=!!(window._composerControlVisibility&&window._composerControlVisibility.hide_composer_status); if(statusHidden){ el.style.display='none'; el.textContent=''; return; } if(!t){ el.style.display='none'; el.textContent=''; return; } // Defensive reset: a stale hidden class should never block live status text. el.classList.remove('composer-control-hidden'); el.removeAttribute('aria-hidden'); el.textContent=t; el.style.display=''; } let _composerLockState=null; let _compressionPlaceholderSaved=null; function lockComposerForClarify(placeholderText){ const input=$('msg'); if(!input) return; // Save the current composer text as a server-side draft before locking, // so the user's draft is preserved if they switch sessions while a clarify // card is active (and survives page refresh / syncs across clients). const sid = S && S.session && S.session.session_id; if (sid && typeof _saveComposerDraftNow === 'function') { _saveComposerDraftNow(sid, input.value || '', S.pendingFiles ? [...S.pendingFiles] : []); } if(!_composerLockState){ _composerLockState={ disabled: input.disabled, placeholder: input.placeholder, }; } input.disabled=true; if(placeholderText) input.placeholder=placeholderText; updateSendBtn(); } function unlockComposerForClarify(){ const input=$('msg'); if(!input) return; if(_composerLockState){ input.disabled=!!_composerLockState.disabled; if(typeof _composerLockState.placeholder==='string'){ input.placeholder=_composerLockState.placeholder; } _composerLockState=null; }else{ input.disabled=false; } updateSendBtn(); } function _composerHasContent(){ const msg=$('msg'); return !!((msg&&msg.value.trim().length>0)||S.pendingFiles.length>0||(typeof window._hasPendingSelections==='function'&&window._hasPendingSelections())); } function _getExplicitBusyCommandAction(text){ const trimmed=(text||'').trim(); if(!trimmed.startsWith('/')) return null; const body=trimmed.slice(1); const name=(body.split(/\s+/)[0]||'').toLowerCase(); const args=body.slice(name.length).trim(); if(!args) return null; if(name==='queue') return 'queue'; if(name==='steer'){ if(S.activeStreamId&&typeof _trySteer==='function') return 'steer'; return 'queue'; } if(name==='interrupt'){ if(S.activeStreamId&&typeof cancelStream==='function') return 'interrupt'; return 'queue'; } return null; } function getComposerPrimaryAction(){ const msg=$('msg'); const hasContent=_composerHasContent(); const locked=!!(msg&&msg.disabled); if(locked) return 'disabled'; const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning(); const isBusy=!!S.busy||compressionRunning; if(!isBusy) return hasContent?'send':'disabled'; if(!hasContent){ if(S.activeStreamId&&typeof cancelStream==='function') return 'stop'; if(compressionRunning) return 'queue'; return 'disabled'; } const explicitAction=_getExplicitBusyCommandAction(msg&&msg.value); if(explicitAction) return explicitAction; const busyMode=window._busyInputMode||'queue'; if(busyMode==='steer'){ if(S.activeStreamId&&typeof _trySteer==='function') return 'steer'; return 'queue'; } if(busyMode==='interrupt'){ if(S.activeStreamId&&typeof cancelStream==='function') return 'interrupt'; return 'queue'; } return 'queue'; } function _setComposerPrimaryButtonIcon(btn,action){ // Queue/interrupt/steer icons are inline Lucide SVGs (ISC): // https://lucide.dev/icons/ const icons={ send:'', queue:'', interrupt:'', steer:'', stop:'', disabled:'' }; const next=icons[action]||icons.send; if(btn.innerHTML!==next) btn.innerHTML=next; } function updateSendBtn(){ const btn=$('btnSend'); if(!btn) return; const action=getComposerPrimaryAction(); btn.dataset.action=action; btn.classList.toggle('stop',action==='stop'); btn.classList.toggle('queue',action==='queue'); btn.classList.toggle('interrupt',action==='interrupt'); btn.classList.toggle('steer',action==='steer'); const _tt=(key,fb)=>{if(typeof t!=='function')return fb;const val=t(key);return val===key?fb:(val||fb);}; let _btnTitle; if(action==='disabled'){ const _dmsg=$('msg'); if(_dmsg&&_dmsg.disabled) _btnTitle=_tt('composer_disabled_clarify','Respond to the clarification request'); else _btnTitle=_tt('composer_disabled_empty','Type a message to send'); }else if(action==='queue'&&typeof isCompressionUiRunning==='function'&&isCompressionUiRunning()){ _btnTitle=_tt('composer_compression_will_queue','Type a message — it will queue and send after compression'); }else{ const _tmap={send:'Send message',queue:'Queue message',interrupt:'Interrupt and send',steer:'Steer current response',stop:'Stop generation'}; _btnTitle=_tt('composer_'+action,_tmap[action]||'Send message'); } btn.title=_btnTitle; btn.setAttribute('aria-label',_btnTitle); _setComposerPrimaryButtonIcon(btn,action); // Single primary action button: while busy/no-draft it becomes the red Stop // action; while busy with a draft it reflects queue/interrupt/steer. btn.style.display=''; btn.disabled=action==='disabled'; if(action!=='disabled'&&!btn.classList.contains('visible')){ btn.classList.remove('visible'); requestAnimationFrame(()=>btn.classList.add('visible')); } else if(action==='disabled'){ btn.classList.remove('visible'); } } async function handleComposerPrimaryAction(){ if(window._micActive){ window._micPendingSend=true; _stopMic(); return; } const action=typeof getComposerPrimaryAction==='function'?getComposerPrimaryAction():'send'; if(action==='disabled') return; if(action==='stop'){ if(typeof cancelStream==='function') await cancelStream(); return; } await send(); } function setBusy(v){ S.busy=v; updateSendBtn(); if(!v){ if(typeof _clearActivityElapsedTimer==='function') _clearActivityElapsedTimer(); setStatus(''); setComposerStatus(''); const sid=_queueDrainSid||(S.session&&S.session.session_id); _queueDrainSid=null; updateQueueBadge(sid); // Drain one queued message for the finished session after UI settles const _isViewedSid=!S.session||sid===S.session.session_id; const next=sid&&_isViewedSid?shiftQueuedSessionMessage(sid):null; if(next){ updateQueueBadge(sid); setTimeout(()=>{ // Guard: if the user switched away from the drain session during // the 120ms settle window, the queued message must NOT go to the // wrong chat. Put it back into the original session's queue and // skip sending — it will drain when the user returns to that session // or when its next stream completes while it is the active view. if(S.session&&S.session.session_id!==sid){ queueSessionMessage(sid,next); updateQueueBadge(sid); return; } $('msg').value=next.text||''; S.pendingFiles=Array.isArray(next.files)?[...next.files]:[]; // Restore model from queued item (sent in /api/chat/start payload) // Note: profile is NOT restored — full profile switch requires server interaction if(next.model&&S.session&&next.model!==S.session.model){ S.session.model=next.model; } if(next.model_provider&&S.session) S.session.model_provider=next.model_provider; if(next.model&&S.session){ if(typeof _applyModelToDropdown==='function'&&$('modelSelect')) _applyModelToDropdown(next.model,$('modelSelect'),S.session.model_provider||null); if(typeof syncModelChip==='function') syncModelChip(); } autoResize(); renderTray(); send(); },120); } } } // ── Queue chip display (Codex Desktop pattern) ───────────────────────────── // Queued messages appear as chips inside #queueChips (above the textarea) // while pending. When the session fires the queued message it becomes a // normal user bubble in the chat — the chip is removed at drain time. const _queueRenderKeys={}; // per-session fingerprint to avoid redundant rebuilds const _queueCollapsed={}; // per-session: true when user explicitly collapsed the card let _queueRenderEpoch=0; function _clearQueueCardDisplay(sid){ const card=document.getElementById('queueCard'); const chips=document.getElementById('queueChips'); if(sid) delete _queueRenderKeys[sid]; if(card) card.classList.remove('visible'); if(chips){ const _chips=chips; const _card=card; const _sid=String(sid||''); const _epoch=_chips.getAttribute('data-queue-render-epoch')||''; setTimeout(()=>{ if((_card&&!_card.classList.contains('visible'))||!_card){ if((_chips.getAttribute('data-queue-render-sid')||'')===_sid&&(_chips.getAttribute('data-queue-render-epoch')||'')===_epoch){ _chips.innerHTML=''; } } },360); } const _msgsEl=document.getElementById('messages'); if(_msgsEl) _msgsEl.classList.remove('queue-open'); _updateQueuePill(sid,0); } function _renderQueueChips(sid){ const card=document.getElementById('queueCard'); const inner=document.getElementById('queueChips'); if(!card||!inner) return; const q=_getSessionQueue(sid,false); const key=q.map(e=>{const t=e&&(e.text||e.message||e.content||'');return(e&&e._queued_at||0)+':'+t.length+':'+t.slice(0,20);}).join('|'); if(key===(_queueRenderKeys[sid]||'')&&key!='') return; // Skip re-render if user is actively editing inside the queue panel if(inner.contains(document.activeElement)&&document.activeElement!==inner) return; _queueRenderKeys[sid]=key; inner.setAttribute('data-queue-render-sid',sid); inner.setAttribute('data-queue-render-epoch',String(++_queueRenderEpoch)); inner.innerHTML=''; if(!q.length){ card.classList.remove('visible'); const _msgs=document.getElementById('messages'); if(_msgs) _msgs.classList.remove('queue-open'); return; } // Respect user-collapsed state — don't reopen if user explicitly hid the card if(_queueCollapsed[sid]){ // Update chips content without showing card (so data is fresh if user re-expands) inner.innerHTML=''; // fall through to render rows into inner but skip making card visible } else { card.classList.add('visible'); } // Push messages area up so content isn't hidden behind the flyout const _msgs=document.getElementById('messages'); if(_msgs&&!_queueCollapsed[sid]){ _msgs.classList.add('queue-open'); // Measure after 350ms transition completes (not mid-animation — height would be wrong) setTimeout(()=>{ if(!card.classList.contains('visible')) return; const h=card.getBoundingClientRect().height; if(h>0) _msgs.style.setProperty('--queue-card-height', h+'px'); if(typeof scrollIfPinned==='function') scrollIfPinned(); }, 360); } function _saveAndRefresh(){ const liveQ=_getSessionQueue(sid,false); if(!liveQ.length){delete SESSION_QUEUES[sid];_clearPersistedSessionQueue(sid);} else{SESSION_QUEUES[sid]=[...liveQ];_persistSessionQueueStorage(sid,liveQ);} delete _queueRenderKeys[sid]; updateQueueBadge(sid); } // Header (2+ items) if(q.length>1){ const header=document.createElement('div'); header.className='queue-card-header'; const lbl=document.createElement('span'); lbl.textContent=typeof t==='function'?t('queued_count',q.length):(q.length===1?'1 queued':`${q.length} queued`); lbl.title='Sends automatically after the current response completes'; const actions=document.createElement('span'); actions.className='queue-card-header-actions'; const hasFiles=q.some(e=>e&&Array.isArray(e.files)&&e.files.length>0); const mergeBtn=document.createElement('button'); mergeBtn.className='queue-card-btn'; mergeBtn.title='Combine all into one message'+(hasFiles?' — attachments will be removed':''); mergeBtn.innerHTML=li('layers',12)+'Combine'; mergeBtn.onclick=()=>{ const _doMerge=(snapshot)=>{ const combined=snapshot.map(e=>e&&(e.text||e.message||e.content||'')).filter(Boolean).join('\n\n'); const liveQ=_getSessionQueue(sid,false); const first=snapshot.find(e=>e)||{}; const firstFiles=(snapshot.find(e=>e&&Array.isArray(e.files)&&e.files.length)||{files:[]}).files; liveQ.length=0;liveQ.push({text:combined,files:firstFiles,model:first.model||'',model_provider:first.model_provider||null,_queued_at:Date.now()}); SESSION_QUEUES[sid]=liveQ; _persistSessionQueueStorage(sid,liveQ); delete _queueRenderKeys[sid]; updateQueueBadge(sid); }; if(hasFiles){ if(typeof showToast==='function') showToast('Attachments on queued items will be removed',2600,'warning'); } // Merge from current live queue (no delay — snapshot + defer caused data-loss races) _doMerge([..._getSessionQueue(sid,false)]); }; const clearBtn=document.createElement('button'); clearBtn.className='queue-card-icon-btn'; clearBtn.title='Clear all queued messages'; clearBtn.setAttribute('aria-label','Clear all queued messages'); clearBtn.innerHTML=li('x',13); clearBtn.onclick=()=>{q.length=0;_saveAndRefresh();}; actions.appendChild(mergeBtn); actions.appendChild(clearBtn); // Hide button — collapses flyout entirely; queue pill re-shows it const hideBtn=document.createElement('button'); hideBtn.className='queue-card-icon-btn'; hideBtn.title='Hide queue (click the queue pill to show again)'; hideBtn.setAttribute('aria-label','Hide queue panel'); hideBtn.innerHTML=li('chevron-down',14); hideBtn.onclick=()=>{ _queueCollapsed[sid]=true; card.classList.remove('visible'); // Read live count at click time (not stale closure q) _updateQueuePill(sid,_getSessionQueue(sid,false).length); }; actions.appendChild(hideBtn); header.appendChild(lbl); header.appendChild(actions); inner.appendChild(header); } let _dragTs=null; // use _queued_at timestamp — survives re-renders, not an index q.forEach((entry,i)=>{ const _entryTs=entry&&entry._queued_at; const entryText=entry&&(entry.text||entry.message||entry.content||''); const _files=entry&&Array.isArray(entry.files)?entry.files.filter(Boolean):[]; const row=document.createElement('div'); row.className='queue-card-row'; row.setAttribute('role','listitem'); row.setAttribute('draggable','true'); row.ondragstart=(e)=>{if(_entryTs==null) return;_dragTs=_entryTs;row.style.opacity='.4';e.dataTransfer.effectAllowed='move';}; row.ondragend=()=>{row.style.opacity='';}; row.ondragover=(e)=>{e.preventDefault();row.style.background='var(--hover-bg)';}; row.ondragleave=()=>{row.style.background='';}; row.ondrop=(e)=>{ e.preventDefault();row.style.background=''; if(_dragTs!=null&&_dragTs!==_entryTs){ const fromIdx=q.findIndex(e=>e&&e._queued_at===_dragTs); if(fromIdx!==-1&&fromIdx!==i){const moved=q.splice(fromIdx,1)[0];q.splice(i,0,moved);} _dragTs=null;_saveAndRefresh(); } }; // Drag handle const drag=document.createElement('span'); drag.className='queue-card-drag'; drag.setAttribute('aria-hidden','true'); drag.innerHTML=typeof li==='function'?li('list-todo',13):'≡'; // Inline-editable text const msgSpan=document.createElement('span'); msgSpan.className='queue-card-text'; msgSpan.setAttribute('contenteditable','true'); msgSpan.setAttribute('role','textbox'); msgSpan.setAttribute('aria-label','Queued message — edit in place'); msgSpan.textContent=entryText||(_files.length?'':'—'); msgSpan.setAttribute('draggable','false'); msgSpan.onfocus=()=>{msgSpan.style.overflow='auto';msgSpan.style.whiteSpace='pre-wrap';msgSpan.style.textOverflow='clip';}; msgSpan.onblur=()=>{ msgSpan.style.overflow='';msgSpan.style.whiteSpace='';msgSpan.style.textOverflow=''; const newText=msgSpan.textContent.trim(); if(newText===''&&!_files.length){ msgSpan.textContent=entryText||'—'; return; } if(newText!==entryText){ const liveQ=_getSessionQueue(sid,false); const idx=_entryTs!=null?liveQ.findIndex(e=>e&&e._queued_at===_entryTs):i; if(idx!==-1){ liveQ[idx]={...liveQ[idx],text:newText}; _persistSessionQueueStorage(sid,liveQ); delete _queueRenderKeys[sid]; updateQueueBadge(sid); } } }; msgSpan.onkeydown=(e)=>{if(e.key==='Enter'){e.preventDefault();msgSpan.blur();}if(e.key==='Escape'){msgSpan.textContent=entryText||'—';msgSpan.blur();}}; // Compact badges (files, model, profile) const badges=document.createElement('span'); badges.className='queue-card-badges'; if(_files.length>0){ const fb=document.createElement('span'); fb.className='queue-card-file-badge'; fb.title=_files.map(f=>f&&f.name||'file').join(', '); fb.innerHTML=li('paperclip',11)+_files.length; badges.appendChild(fb); } const _model=entry&&entry.model; if(_model){ const mb=document.createElement('span'); mb.title='Model: '+_model; // Use the app's friendly label system if available const _modelLabel=(typeof _dynamicModelLabels!=='undefined'&&_dynamicModelLabels[_model]) ||_model.split('/').pop().replace(/^(gpt-|claude-3\.?5?-|claude-|gemini-)/,'').replace(/-\d{4}-\d{2}-\d{2}$/,'').slice(0,12); mb.textContent=_modelLabel; badges.appendChild(mb); } // Profile badge removed — drain cannot server-switch profiles so badge was misleading // Delete button const delBtn=document.createElement('button'); delBtn.className='queue-card-icon-btn'; delBtn.setAttribute('aria-label',typeof t==='function'?t('queued_cancel'):'Remove queued message'); delBtn.setAttribute('draggable','false'); delBtn.title='Remove from queue'; delBtn.innerHTML=li('x',13); delBtn.onclick=()=>{ const liveQ=_getSessionQueue(sid,false); const idx=_entryTs!=null?liveQ.findIndex(e=>e&&e._queued_at===_entryTs):i; if(idx!==-1) liveQ.splice(idx,1); if(!liveQ.length){delete SESSION_QUEUES[sid];_clearPersistedSessionQueue(sid);} else{SESSION_QUEUES[sid]=[...liveQ];_persistSessionQueueStorage(sid,liveQ);} delete _queueRenderKeys[sid]; updateQueueBadge(sid); }; row.appendChild(drag); row.appendChild(msgSpan); if(badges.childNodes.length) row.appendChild(badges); row.appendChild(delBtn); inner.appendChild(row); }); } function _updateQueuePill(sid,count){ const pill=document.getElementById('queuePill'); if(!pill) return; const pillOuter=pill.parentElement; // .queue-pill-outer — same wrapper as .queue-card const card=document.getElementById('queueCard'); const flyoutVisible=card&&card.classList.contains('visible'); if(count>0&&!flyoutVisible){ const label=typeof t==='function'?t('queued_count',count):(count===1?'1 queued':`${count} queued`); pill.innerHTML=(typeof li==='function'?li('list-todo',12):'')+ `${label}`+ ``+(typeof li==='function'?li('chevron-up',12):'▲')+``; pill.title='Show queued messages'; if(pillOuter) pillOuter.classList.add('show'); pill.onclick=()=>{ delete _queueCollapsed[sid]; const c=document.getElementById('queueCard'); if(c){ c.classList.add('visible'); setTimeout(()=>{ const firstFocusable=c.querySelector('.queue-card-text, .queue-card-icon-btn'); if(firstFocusable) firstFocusable.focus(); }, 360); } if(pillOuter) pillOuter.classList.remove('show'); if(typeof scrollIfPinned==='function') scrollIfPinned(); }; } else { if(pillOuter) pillOuter.classList.remove('show'); pill.onclick=null; } } function updateQueueBadge(sessionId){ const sid=sessionId||(S.session&&S.session.session_id); const count=sid?getQueuedSessionCount(sid):0; if(count>0&&S.session&&sid===S.session.session_id){ _renderQueueChips(sid); // If card is visible, hide pill. If card is collapsed, update pill count. const _cardEl=document.getElementById('queueCard'); _updateQueuePill(sid,(_cardEl&&_cardEl.classList.contains('visible'))?0:count); } else { // Always clean up per-session data if(sid){delete _queueRenderKeys[sid];delete _queueCollapsed[sid];} // Only wipe global DOM if this is the currently active session const isActive=S.session&&sid===S.session.session_id; if(isActive){ _clearQueueCardDisplay(sid); } } } const TOAST_DEFAULT_MS=2800; const TOAST_ERROR_DEFAULT_MS=20000; function clearToastDismissTimer(el){if(!el)return;clearTimeout(el._t);el._t=null;} function setToastDismissTimer(el,duration){if(!el)return;clearToastDismissTimer(el);el._t=setTimeout(()=>{el.classList.remove('show');},duration);} function dismissToast(btnOrEl){ const el=btnOrEl&&btnOrEl.closest?btnOrEl.closest('#toast'):(btnOrEl&&btnOrEl.id==='toast'?btnOrEl:null); if(!el)return; clearToastDismissTimer(el); el.classList.remove('show'); } function copyToastText(btn){ const el=btn&&btn.closest?btn.closest('#toast'):null; const text=el?(el.dataset.toastMessage||el.textContent||''):''; const done=()=>{const old=btn.textContent;btn.textContent='Copied';setTimeout(()=>{btn.textContent=old;},1200);}; _copyText(text).then(done).catch(()=>{}); } function showToast(msg,ms,type){ const el=$('toast');if(!el)return; const s=String(msg==null?'':msg);let t=type; if(!t){const low=s.toLowerCase();if(/fail|error|denied|invalid|unavailable|no active|no workspace match|no model match|no personalities/.test(low))t='error';else if(/warn|queued|takes effect|skipped|fallback/.test(low))t='warning';else if(/saved|created|imported|restored|switched|set to|updated|duplicated|moved to|renamed|deleted|complete|pinned|archived|cleared|stopped/.test(low))t='success';else t='info';} const duration=(ms==null)?(t==='error'?TOAST_ERROR_DEFAULT_MS:TOAST_DEFAULT_MS):ms; el.className='toast show '+t; el.dataset.toastMessage=s; if(t==='error') el.innerHTML=`${esc(s)}`; else el.textContent=s; el.onmouseenter=()=>clearToastDismissTimer(el); el.onmouseleave=()=>setToastDismissTimer(el,duration); el.onfocusin=()=>clearToastDismissTimer(el); el.onfocusout=()=>setToastDismissTimer(el,duration); el.onclick=t==='error'?null:()=>dismissToast(el); setToastDismissTimer(el,duration); } // ── Shared app dialogs ─────────────────────────────────────────────────────── // showConfirmDialog(opts) and showPromptDialog(opts) replace browser-native dialog calls // throughout the UI. Both return Promises and support: title, message, confirmLabel, // cancelLabel, danger (confirm only), placeholder/value/inputType (prompt only). const APP_DIALOG={resolve:null,kind:null,lastFocus:null}; let _appDialogBound=false; function _isAppDialogOpen(){ const overlay=$('appDialogOverlay'); return !!(overlay&&overlay.style.display!=='none'); } function _getAppDialogFocusable(){ return [$('appDialogInput'), $('appDialogCancel'), $('appDialogConfirm'), $('appDialogClose')] .filter(el=>el&&el.style.display!=='none'&&!el.disabled); } function _finishAppDialog(result, restoreFocus=true){ const overlay=$('appDialogOverlay'); const dialog=$('appDialog'); const input=$('appDialogInput'); const confirmBtn=$('appDialogConfirm'); const resolve=APP_DIALOG.resolve; const lastFocus=APP_DIALOG.lastFocus; APP_DIALOG.resolve=null; APP_DIALOG.kind=null; APP_DIALOG.lastFocus=null; if(overlay){overlay.style.display='none';overlay.setAttribute('aria-hidden','true');} if(dialog) dialog.setAttribute('role','dialog'); if(input){input.value='';input.style.display='none';input.placeholder='';} if(confirmBtn){confirmBtn.classList.remove('danger');confirmBtn.textContent=t('dialog_confirm_btn');} if(restoreFocus&&lastFocus&&typeof lastFocus.focus==='function'){setTimeout(()=>lastFocus.focus(),0);} if(resolve) resolve(result); } function _ensureAppDialogBindings(){ if(_appDialogBound) return; _appDialogBound=true; const overlay=$('appDialogOverlay'); const cancelBtn=$('appDialogCancel'); const confirmBtn=$('appDialogConfirm'); const closeBtn=$('appDialogClose'); if(overlay){ overlay.addEventListener('click',e=>{ if(e.target===overlay) _finishAppDialog(APP_DIALOG.kind==='prompt'?null:false); }); } if(cancelBtn) cancelBtn.addEventListener('click',()=>_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false)); if(closeBtn) closeBtn.addEventListener('click',()=>_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false)); if(confirmBtn){ confirmBtn.addEventListener('click',()=>{ if(APP_DIALOG.kind==='prompt'){ const input=$('appDialogInput'); _finishAppDialog(input?input.value:null); }else{ _finishAppDialog(true); } }); } document.addEventListener('keydown',e=>{ if(!_isAppDialogOpen()) return; if(e.key==='Escape'){ e.preventDefault(); _finishAppDialog(APP_DIALOG.kind==='prompt'?null:false); return; } if(e.key==='Enter'){ if(window._isImeEnter&&window._isImeEnter(e)) return; const target=e.target; const isTextarea=target&&target.tagName==='TEXTAREA'; if(!isTextarea){ e.preventDefault(); if(target===cancelBtn||target===closeBtn){ _finishAppDialog(APP_DIALOG.kind==='prompt'?null:false); }else if(APP_DIALOG.kind==='prompt'){ const input=$('appDialogInput'); _finishAppDialog(input?input.value:null); }else{ _finishAppDialog(true); } } return; } if(e.key==='Tab'){ const nodes=_getAppDialogFocusable(); if(!nodes.length) return; const idx=nodes.indexOf(document.activeElement); let nextIdx=idx; if(e.shiftKey){nextIdx=idx<=0?nodes.length-1:idx-1;} else{nextIdx=idx===-1||idx===nodes.length-1?0:idx+1;} e.preventDefault(); nodes[nextIdx].focus(); } }, true); } function showConfirmDialog(opts={}){ _ensureAppDialogBindings(); if(APP_DIALOG.resolve) _finishAppDialog(false,false); const overlay=$('appDialogOverlay'),dialog=$('appDialog'),title=$('appDialogTitle'), desc=$('appDialogDesc'),input=$('appDialogInput'),cancelBtn=$('appDialogCancel'),confirmBtn=$('appDialogConfirm'); APP_DIALOG.resolve=null;APP_DIALOG.kind='confirm';APP_DIALOG.lastFocus=document.activeElement; if(title) title.textContent=opts.title||t('dialog_confirm_title'); if(desc) desc.textContent=opts.message||''; if(input){input.style.display='none';input.value='';} if(cancelBtn){ if(opts.hideCancel){cancelBtn.style.display='none';} else{cancelBtn.style.display='';cancelBtn.textContent=opts.cancelLabel||t('cancel');} } if(confirmBtn){ confirmBtn.textContent=opts.confirmLabel||t('dialog_confirm_btn'); confirmBtn.classList.toggle('danger',!!opts.danger); } if(dialog) dialog.setAttribute('role',opts.danger?'alertdialog':'dialog'); if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');} return new Promise(resolve=>{ APP_DIALOG.resolve=resolve; setTimeout(()=>((opts.focusCancel?cancelBtn:confirmBtn)||confirmBtn||cancelBtn).focus(),0); }); } function showPromptDialog(opts={}){ _ensureAppDialogBindings(); if(APP_DIALOG.resolve) _finishAppDialog(null,false); const overlay=$('appDialogOverlay'),dialog=$('appDialog'),title=$('appDialogTitle'), desc=$('appDialogDesc'),input=$('appDialogInput'),cancelBtn=$('appDialogCancel'),confirmBtn=$('appDialogConfirm'); APP_DIALOG.resolve=null;APP_DIALOG.kind='prompt';APP_DIALOG.lastFocus=document.activeElement; if(title) title.textContent=opts.title||t('dialog_prompt_title'); if(desc) desc.textContent=opts.message||''; if(input){ input.type=opts.inputType||'text';input.style.display=''; // Pre-fill: prefer `value`, accept `defaultValue` as alias for callers that // mirror the standard HTMLInputElement.defaultValue naming. Both empty → // blank field (the default rename-from-scratch flow stays unchanged). const prefill=(opts.value!=null?opts.value:(opts.defaultValue!=null?opts.defaultValue:'')); input.value=prefill;input.placeholder=opts.placeholder||''; input.autocomplete='off';input.spellcheck=false; } if(cancelBtn){ // A prior showConfirmDialog({hideCancel:true}) (e.g. the outside-symlink info // dialog, #4581) may have hidden the shared Cancel button; always restore it // so a subsequent prompt keeps its Cancel affordance. cancelBtn.style.display=''; cancelBtn.textContent=opts.cancelLabel||t('cancel'); } if(confirmBtn){ confirmBtn.textContent=opts.confirmLabel||t('create'); confirmBtn.classList.toggle('danger',!!opts.danger); } if(dialog) dialog.setAttribute('role',opts.danger?'alertdialog':'dialog'); if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');} return new Promise(resolve=>{ APP_DIALOG.resolve=resolve; setTimeout(()=>{ if(input&&input.style.display!=='none'){ input.focus(); // Selection behavior on focus: // selectStem:true → select everything before the LAST '.' (e.g. for // 'report.txt' selects 'report' so a user can retype the basename // without losing the extension; matches macOS Finder rename UX). // Falls back to selecting the full value when there's no '.' or // the dot is at index 0 ('.gitignore' → full select). // selectAll:true → select the entire prefilled value. // default → caret at end (current behavior). const v=input.value||''; if(opts.selectStem && v){ const dot=v.lastIndexOf('.'); if(dot>0) input.setSelectionRange(0,dot); else input.select(); } else if(opts.selectAll && v){ input.select(); } } else if(confirmBtn) confirmBtn.focus(); },0); }); } function _copyText(text){ if(navigator.clipboard && window.isSecureContext){ return navigator.clipboard.writeText(text).catch(()=>{ // Fallback if clipboard API fails (e.g. permissions) return _fallbackCopy(text); }); } return _fallbackCopy(text); } function _fallbackCopy(text){ return new Promise((resolve,reject)=>{ const ta=document.createElement('textarea'); ta.value=text;ta.style.cssText='position:fixed;left:0;top:0;width:2em;height:2em;padding:0;border:none;outline:none;box-shadow:none;background:transparent;z-index:-1'; document.body.appendChild(ta); ta.focus();ta.select(); try{document.execCommand('copy');resolve();} catch(e){reject(e);} finally{document.body.removeChild(ta);} }); } function copyStatusSessionId(btn){ const text=btn&&btn.getAttribute('data-copy-status-session'); if(!text)return; _copyText(text).then(()=>{ const orig=btn.innerHTML; btn.innerHTML=(typeof li==='function')?li('check',13):t('copied'); btn.classList.add('copied'); setTimeout(()=>{btn.innerHTML=orig;btn.classList.remove('copied');},1500); }).catch(()=>showToast(t('copy_failed'))); } function copyMsg(btn){ const row=btn.closest('[data-raw-text]'); const text=row?row.dataset.rawText:''; if(!text)return; _copyText(text).then(()=>{ const orig=btn.innerHTML;btn.innerHTML=li('check',13);btn.style.color='var(--blue)'; setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500); }).catch(()=>showToast(t('copy_failed'))); } function _copyThinkingText(btn){ const card=btn&&btn.closest?btn.closest('.thinking-card'):null; if(!card)return; const pre=card.querySelector('.thinking-card-body pre'); const text=pre?pre.textContent:''; if(!text)return; _copyText(text).then(()=>{ const orig=btn.innerHTML; btn.innerHTML=li('check',12); btn.style.color='var(--accent)'; setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500); }).catch(()=>showToast(t('copy_failed'))); } // ── TTS: Text-to-Speech via Web Speech API (#499) ── // Strips markdown, code blocks, and MEDIA: paths for clean speech output. function _stripForTTS(text){ // Remove code blocks entirely (```) — line-anchored to match #1438 fix text=text.replace(/(^|\n)[ ]{0,3}```(?:[\s\S]*?\n)?[ ]{0,3}```(?=\n|$)/g,' '); // Remove inline code text=text.replace(/`[^`]+`/g,' '); // Strip bold/italic text=text.replace(/\*\*(.+?)\*\*/g,'$1'); text=text.replace(/\*(.+?)\*/g,'$1'); text=text.replace(/__(.+?)__/g,'$1'); text=text.replace(/_(.+?)_/g,'$1'); // Strip headings text=text.replace(/^#{1,6}\s+/gm,''); // Strip links, keep text text=text.replace(/\[([^\]]+)\]\([^)]+\)/g,'$1'); // Replace MEDIA: paths with a simple label text=text.replace(/MEDIA:[^\s]+/g,'a file'); // Strip emoji and emoticons text=text.replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}]/gu,''); // Strip HTML tags that may leak through markdown text=text.replace(/<[^>]+>/g,' '); // Collapse whitespace text=text.replace(/\s+/g,' ').trim(); return text; } function _splitForTTS(text, maxChars){ // Split long text into chunks at natural sentence/paragraph boundaries // to avoid browser SpeechSynthesis truncation on long texts. maxChars=maxChars||300; if(text.length<=maxChars) return [text]; const chunks=[]; let remaining=text; while(remaining.length>0){ if(remaining.length<=maxChars){ chunks.push(remaining); break; } let splitAt=maxChars; const sentencePattern=new RegExp('^[\\s\\S]{0,'+(maxChars-1)+'}[。!?.!?](?=\\s|$)','g'); const m=sentencePattern.exec(remaining); if(m) splitAt=m.index+m[0].length; else{ const sub=remaining.slice(0,maxChars); const lastSpace=Math.max(sub.lastIndexOf(' '),sub.lastIndexOf('\n'),sub.lastIndexOf(','),sub.lastIndexOf(',')); if(lastSpace>maxChars*0.5) splitAt=lastSpace+1; } chunks.push(remaining.slice(0,splitAt).trim()); remaining=remaining.slice(splitAt).trim(); } return chunks.filter(Boolean); } let _ttsSpeaking=false; let _ttsCurrentUtterance=null; let _ttsChunkQueue=[]; let _ttsChunkIndex=0; let _ttsActiveBtn=null; let _playingEdgeAudio=null; function _buildBrowserUtterance(text, btn){ const utter=new SpeechSynthesisUtterance(text); const savedVoice=localStorage.getItem('hermes-tts-voice'); const voices=speechSynthesis.getVoices(); if(savedVoice&&voices.length){ const match=voices.find(v=>v.name===savedVoice); if(match) utter.voice=match; } const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate')); if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate)); const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch')); if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch)); utter.onend=()=>{ _ttsChunkIndex++; if(_ttsChunkIndex<_ttsChunkQueue.length){ const next=new SpeechSynthesisUtterance(_ttsChunkQueue[_ttsChunkIndex]); next.voice=utter.voice; next.rate=utter.rate; next.pitch=utter.pitch; next.onend=utter.onend; next.onerror=utter.onerror; _ttsCurrentUtterance=next; speechSynthesis.speak(next); } else { _ttsSpeaking=false; _ttsCurrentUtterance=null; _ttsChunkQueue=[]; _ttsChunkIndex=0; _ttsActiveBtn=null; if(btn) btn.dataset.speaking='0'; } }; utter.onerror=()=>{ _ttsSpeaking=false; _ttsCurrentUtterance=null; _ttsChunkQueue=[]; _ttsChunkIndex=0; _ttsActiveBtn=null; if(btn) btn.dataset.speaking='0'; }; return utter; } function _playEdgeTtsChunked(text, btn){ _ttsSpeaking=true; if(btn) btn.dataset.speaking='1'; const chunks=_splitForTTS(text); const _playOne=function(idx){ if(idx>=chunks.length){ _ttsSpeaking=false;_playingEdgeAudio=null; if(btn) btn.dataset.speaking='0'; return; } const chunk=chunks[idx]; const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural'; const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate')); const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch')); let rate='', pitch=''; if(!isNaN(savedRate)){const pct=Math.round((savedRate-1)*100);const sign=pct>=0?'+':'';rate=sign+pct+'%';} if(!isNaN(savedPitch)){const hz=Math.round((savedPitch-1)*50);const sign=hz>=0?'+':'';pitch=sign+hz+'Hz';} fetch(new URL('api/tts', document.baseURI || location.href).href, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch}) }) .then(function(r){ if(!r.ok){ return r.json().catch(function(){return {};}).then(function(j){ throw new Error((j&&j.error)||('TTS request failed: '+r.status)); }); } return r.blob(); }) .then(function(blob){ if(!_ttsSpeaking) return; const url=URL.createObjectURL(blob); const audio=new Audio(url); _playingEdgeAudio=audio; audio.onended=function(){ URL.revokeObjectURL(url); _playingEdgeAudio=null; if(_ttsSpeaking) _playOne(idx+1); }; audio.onerror=function(){ URL.revokeObjectURL(url); _playingEdgeAudio=null; _ttsSpeaking=false; if(btn) btn.dataset.speaking='0'; }; audio.play().catch(function(e){ URL.revokeObjectURL(url); _playingEdgeAudio=null; _ttsSpeaking=false; if(btn) btn.dataset.speaking='0'; if(typeof showToast==='function') showToast('Edge TTS error: '+(e&&e.message||e)); }); }) .catch(function(e){ _ttsSpeaking=false;_playingEdgeAudio=null; if(btn) btn.dataset.speaking='0'; if(typeof showToast==='function') showToast('Edge TTS failed: '+(e&&e.message||e)); }); }; _playOne(0); } function speakMessage(btn){ if(btn&&btn.dataset.speaking==='1'){ stopTTS(); return; } stopTTS(); const row=btn?btn.closest('[data-raw-text]'):null; const text=row?row.dataset.rawText:''; if(!text) return; const clean=_stripForTTS(text); if(!clean) return; const engine=localStorage.getItem('hermes-tts-engine')||'browser'; if(engine==='elevenlabs'){ _playElevenLabsTts(clean, btn); return; } if(engine==='edge'){ _playEdgeTtsChunked(clean, btn); return; } // Extension-registered TTS engine (window.registerHermesTtsEngine). Synthesize // via the extension, then play through the shared audio-buffer path. if(typeof window._hermesTtsIsRegistered==='function' && window._hermesTtsIsRegistered(engine)){ if(btn) btn.dataset.speaking='1'; _ttsSpeaking=true; const _failReg=function(msg){ _ttsSpeaking=false;_playingEdgeAudio=null; if(btn)btn.dataset.speaking='0'; if(msg&&typeof showToast==='function') showToast(msg,4000,'error'); }; const _opts={ voice: localStorage.getItem('hermes-tts-voice')||'', rate: parseFloat(localStorage.getItem('hermes-tts-rate')), pitch: parseFloat(localStorage.getItem('hermes-tts-pitch')), }; Promise.resolve(window._hermesTtsSynth(engine, clean, _opts)) .then(function(buf){ return _playAudioBuf(buf, btn, 'TTS'); }) .catch(function(e){ _failReg((e&&e.message)||'TTS engine failed'); }); return; } if(!('speechSynthesis' in window)){ showToast(t('tts_not_supported')||'Speech synthesis not supported in this browser.'); return; } _ttsChunkQueue=_splitForTTS(clean); _ttsChunkIndex=0; _ttsActiveBtn=btn; _ttsSpeaking=true; if(btn) btn.dataset.speaking='1'; const utter=_buildBrowserUtterance(_ttsChunkQueue[0], btn); _ttsCurrentUtterance=utter; speechSynthesis.speak(utter); } function _playElevenLabsTts(text, btn){ if(btn) btn.dataset.speaking='1'; _ttsSpeaking=true; const _fail=function(msg){ _ttsSpeaking=false;_playingEdgeAudio=null; if(btn)btn.dataset.speaking='0'; if(msg&&typeof showToast==='function') showToast(msg,4000,'error'); }; fetch(new URL('api/tts', document.baseURI || location.href).href, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({text:text, engine:'elevenlabs'}) }) .then(function(r){ if(!r.ok){ return r.json().catch(function(){return {};}).then(function(j){ throw new Error((j&&j.error)||('TTS request failed: '+r.status)); }); } return r.arrayBuffer(); }) .then(function(buf){ return _playAudioBuf(buf, btn, 'ElevenLabs TTS'); }) .catch(function(e){ _fail((e&&e.message)||'ElevenLabs TTS failed'); }); } // ── Shared AudioContext for TTS playback (no blob URLs needed) ── let _ttsAudioCtx=null; function _getTtsAudioCtx(){ if(!_ttsAudioCtx){ const C=window.AudioContext||window.webkitAudioContext; if(!C) return null; _ttsAudioCtx=new C(); } if(_ttsAudioCtx.state==='suspended') _ttsAudioCtx.resume(); return _ttsAudioCtx; } function _playAudioBuf(arrayBuffer, btn, label){ const ctx=_getTtsAudioCtx(); if(!ctx){ if(btn)btn.dataset.speaking='0'; _ttsSpeaking=false; showToast(label+': Web Audio API not available'); return; } return new Promise(function(resolve){ ctx.decodeAudioData(arrayBuffer.slice(0), function(audioBuffer){ const src=ctx.createBufferSource(); src.buffer=audioBuffer; src.connect(ctx.destination); _playingEdgeAudio=src; const _cleanup=function(){ _ttsSpeaking=false;_playingEdgeAudio=null; if(btn)btn.dataset.speaking='0'; try{src.stop();src.disconnect();}catch(_){} resolve(); }; src.onended=_cleanup; src.start(0); }, function(e){ _ttsSpeaking=false; if(btn)btn.dataset.speaking='0'; showToast(label+' error: '+(e&&e.message||e)); resolve(); // prevent permanently pending Promise on decode failure }); }); } function stopTTS(){ if('speechSynthesis' in window){ speechSynthesis.cancel(); } // Stop Web Audio API playback (AudioBufferSourceNode) if(_playingEdgeAudio){ try{ if(typeof _playingEdgeAudio.stop==='function'){ _playingEdgeAudio.stop(); _playingEdgeAudio.disconnect(); }else{ _playingEdgeAudio.pause(); _playingEdgeAudio.currentTime=0; } }catch(_){} _playingEdgeAudio=null; } _ttsSpeaking=false; _ttsCurrentUtterance=null; _ttsChunkQueue=[]; _ttsChunkIndex=0; _ttsActiveBtn=null; // Reset all speaking buttons document.querySelectorAll('[data-speaking="1"]').forEach(btn=>{ btn.dataset.speaking='0'; }); } function autoReadLastAssistant(){ const engine=localStorage.getItem('hermes-tts-engine')||'browser'; if(engine==='browser'&&!('speechSynthesis' in window)) return; const pref=localStorage.getItem('hermes-tts-auto-read'); if(pref!=='true') return; // Find the last assistant message segment in the DOM const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]'); if(!rows.length) return; const last=rows[rows.length-1]; const text=last.dataset.rawText||''; if(!text.trim()) return; const clean=_stripForTTS(text); if(!clean) return; if(engine==='elevenlabs'){ _playElevenLabsTts(clean, null); return; } if(engine==='edge'){ _playEdgeTtsChunked(clean, null); return; } // Extension-registered TTS engine (window.registerHermesTtsEngine): synth via // the extension, then play through the shared audio-buffer path. Mirrors the // registered-engine branch in speakMessage() so auto-read honors the selection. if(typeof window._hermesTtsIsRegistered==='function' && window._hermesTtsIsRegistered(engine)){ _ttsSpeaking=true; const _opts={ voice: localStorage.getItem('hermes-tts-voice')||'', rate: parseFloat(localStorage.getItem('hermes-tts-rate')), pitch: parseFloat(localStorage.getItem('hermes-tts-pitch')), }; Promise.resolve(window._hermesTtsSynth(engine, clean, _opts)) .then(function(buf){ return _playAudioBuf(buf, null, 'TTS'); }) .catch(function(){ _ttsSpeaking=false; _playingEdgeAudio=null; }); return; } // Unknown/unregistered engine (e.g. an extension engine that's no longer // registered) — fall back to browser TTS only if it's available. if(!('speechSynthesis' in window)) return; // Use chunked playback for browser TTS _ttsChunkQueue=_splitForTTS(clean); _ttsChunkIndex=0; _ttsSpeaking=true; const utter=_buildBrowserUtterance(_ttsChunkQueue[0], null); _ttsCurrentUtterance=utter; speechSynthesis.speak(utter); } // ── Reconnect banner (B4/B5: reload resilience) ── const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking const INFLIGHT_STATE_KEY = 'hermes-webui-inflight-state'; // localStorage snapshots for mid-stream reload recovery const INFLIGHT_STATE_DEFAULT_LIMITS = { maxSessions:8, messages:24, toolCalls:48, stringChars:60000, jsonChars:1500000, }; function _boundedInflightInt(value, fallback, min, max){ const n=parseInt(value,10); if(!Number.isFinite(n)) return fallback; return Math.max(min, Math.min(max, n)); } function _getInflightStateLimits(){ const configured=(typeof window!=='undefined'&&window._inflightStateLimits&&typeof window._inflightStateLimits==='object')?window._inflightStateLimits:{}; return { maxSessions:_boundedInflightInt(configured.maxSessions, INFLIGHT_STATE_DEFAULT_LIMITS.maxSessions, 1, 25), messages:_boundedInflightInt(configured.messages, INFLIGHT_STATE_DEFAULT_LIMITS.messages, 1, 100), toolCalls:_boundedInflightInt(configured.toolCalls, INFLIGHT_STATE_DEFAULT_LIMITS.toolCalls, 1, 200), stringChars:_boundedInflightInt(configured.stringChars, INFLIGHT_STATE_DEFAULT_LIMITS.stringChars, 1000, 500000), jsonChars:_boundedInflightInt(configured.jsonChars, INFLIGHT_STATE_DEFAULT_LIMITS.jsonChars, 100000, 4000000), }; } function _readInflightStateMap(){ try{ const raw=localStorage.getItem(INFLIGHT_STATE_KEY); const parsed=raw?JSON.parse(raw):{}; return parsed&&typeof parsed==='object'?parsed:{}; }catch(_){ return {}; } } function _isStorageQuotaError(err){ return !!err && ( err.name==='QuotaExceededError' || err.name==='NS_ERROR_DOM_QUOTA_REACHED' || err.code===22 || err.code===1014 ); } function _truncateInflightValue(value, maxChars){ const limits=_getInflightStateLimits(); const stringLimit=_boundedInflightInt(maxChars, limits.stringChars, 1000, 500000); if(typeof value==='string'){ if(value.length<=stringLimit) return value; return value.slice(0,stringLimit)+'\n\n[truncated for browser recovery storage]'; } if(Array.isArray(value)) return value.map(v=>_truncateInflightValue(v, Math.max(2000, Math.floor(stringLimit/2)))); if(value&&typeof value==='object'){ const out={}; for(const [k,v] of Object.entries(value)) out[k]=_truncateInflightValue(v, stringLimit); return out; } return value; } function _compactInflightState(state){ const limits=_getInflightStateLimits(); const messages=Array.isArray(state.messages)?state.messages.slice(-limits.messages):[]; const toolCalls=Array.isArray(state.toolCalls)?state.toolCalls.slice(-limits.toolCalls):[]; // Phase 2: persist the live todo snapshot so reload / SSE reattach // restores the panel without waiting for the next live `todo` write. // The list is bounded by the agent (typically <20 items) and each // item is small, so no per-list cap is needed beyond the existing // stringChars truncation in _truncateInflightValue. const todos=Array.isArray(state.todos)?state.todos:null; const todoStateMeta=(state.todoStateMeta&&typeof state.todoStateMeta==='object')?state.todoStateMeta:null; return _truncateInflightValue({ streamId:state.streamId||null, messages, uploaded:Array.isArray(state.uploaded)?state.uploaded.slice(-20):[], toolCalls, lastAssistantText:state.lastAssistantText||'', lastReasoningText:state.lastReasoningText||'', lastRunJournalSeq:state.lastRunJournalSeq||0, journalReplayFromStart:!!state.journalReplayFromStart, currentActivityBurstId:state.currentActivityBurstId||0, currentLiveSegmentSeq:state.currentLiveSegmentSeq||0, activityBurstAnchors:Array.isArray(state.activityBurstAnchors)?state.activityBurstAnchors.slice(-50):[], todos, todoStateMeta, }, limits.stringChars); } function _writeInflightStateMap(all){ const limits=_getInflightStateLimits(); const entries=Object.entries(all||{}) .sort((a,b)=>Number(b[1]&&b[1].updated_at||0)-Number(a[1]&&a[1].updated_at||0)) .slice(0,limits.maxSessions); const compact={}; for(const [sid,entry] of entries) compact[sid]=entry; let json=JSON.stringify(compact); if(json.length>limits.jsonChars){ const current=entries[0]; json=JSON.stringify(current?{[current[0]]:current[1]}:{}); } if(json.length>limits.jsonChars){ localStorage.removeItem(INFLIGHT_STATE_KEY); return false; } localStorage.setItem(INFLIGHT_STATE_KEY,json); return true; } function saveInflightState(sid, state){ if(!sid||!state) return; const entry={..._compactInflightState(state),updated_at:Date.now()}; try{ const all=_readInflightStateMap(); all[sid]=entry; _writeInflightStateMap(all); }catch(err){ if(!_isStorageQuotaError(err)) return; try{ localStorage.removeItem(INFLIGHT_STATE_KEY); _writeInflightStateMap({[sid]:entry}); }catch(_){ try{localStorage.removeItem(INFLIGHT_STATE_KEY);}catch(__){} } } } function loadInflightState(sid, streamId){ if(!sid) return null; const all=_readInflightStateMap(); const entry=all[sid]; if(!entry) return null; if(streamId&&entry.streamId&&entry.streamId!==streamId) return null; if(entry.updated_at&&Date.now()-entry.updated_at>10*60*1000){ clearInflightState(sid); return null; } return entry; } function clearInflightState(sid){ if(!sid) return; try{ const all=_readInflightStateMap(); if(!(sid in all)) return; delete all[sid]; if(Object.keys(all).length) localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all)); else localStorage.removeItem(INFLIGHT_STATE_KEY); }catch(_){ } } // ─── Todo state: single source of truth + render scheduling ───────────────── // // Three concerns live together so they can share state cleanly: // // 1. _todosHash(items) — cheap content fingerprint; skips re-render when // a snapshot would paint the same DOM. Used both as a short-circuit // and as the hash that compares "rendered vs current" snapshots. // // 2. scheduleTodosRefresh() — coalesces multiple `todo_state` events that // land in the same animation frame into a single refresh pass. It keeps // the left sidebar Todos behavior unchanged, and also lets the workspace // Todos tab repaint when that tab is enabled and currently visible. // // 3. _hydrateTodosFromSession(session) — applies cold-load todo_state // from the session GET payload, or clears the panel when neither a // cold-load nor an INFLIGHT signal is available. Called at every // `S.session = ...` settle point so cross-session navigation never // leaves a stale list visible. // // The hash is keyed on (id, content/text, status); the render itself uses // `esc()` for any user-controlled string, so XSS surface is the same as // any other innerHTML path in this file. let _todosLastRenderedHash=null; let _todosRenderRafId=0; function _todosHash(items){ if(!Array.isArray(items)) return ''; // String concat outperforms JSON.stringify on small arrays in V8 (no // intermediate object allocation) and is exact enough — the field set // matches what the renderer reads, so any visible change in DOM // implies a hash change. Field separators (\x1f, \x1e) are control // chars unlikely to appear in real todo content, so collisions across // boundaries are not realistic. let h=items.length+'|'; for(let i=0;i${esc(t('todos_no_active'))}
`; } function renderTodoRow(todo,options={}){ const td=todo||{}; const status=todoStatusKey(td.status); const visual=todoStatusVisual(status); const showMetadata=!(options&&options.metadata===false); const isCompleted=status==='completed'; const isCancelled=status==='cancelled'; const contentColor=(isCompleted||isCancelled)?'var(--muted)':'var(--text)'; const completedStyle=(isCompleted||isCancelled)?'text-decoration:line-through;opacity:.5':''; const metadata=showMetadata ? `
${esc(td.id)} · ${esc(status)}
` : ''; return `
${renderTodoStatusIcon(status,14)}
${esc(todoContent(td))}
${metadata}
`; } function renderTodoRows(todos,options={}){ const items=Array.isArray(todos)?todos:[]; return items.map(td=>renderTodoRow(td,options)).join(''); } function _todosPanelIsActive(){ if(typeof document==='undefined') return false; const panel=document.getElementById('panelTodos'); return !!(panel&&panel.classList&&panel.classList.contains('active')); } function scheduleTodosRefresh(){ // Idempotent: many `todo_state` events fire on each tool result, but // only the latest snapshot needs to paint. RAF lets us coalesce // without timer drift. if(_todosRenderRafId) return; if(typeof requestAnimationFrame!=='function'){ if(typeof loadTodos==='function') loadTodos(); if(typeof _refreshWorkspacePanelTodos==='function') _refreshWorkspacePanelTodos(); return; } _todosRenderRafId=requestAnimationFrame(()=>{ _todosRenderRafId=0; const sidebarActive=_todosPanelIsActive(); if(sidebarActive&&typeof loadTodos==='function') loadTodos(); if(typeof _refreshWorkspacePanelTodos==='function') _refreshWorkspacePanelTodos(); }); } function _resetTodosRenderCache(){ // Clear after every cross-session navigation so the next render is // never short-circuited against a hash from a different session. _todosLastRenderedHash=null; if(typeof _resetWorkspaceTodosRenderCache==='function') _resetWorkspaceTodosRenderCache(); } function _hydrateTodosFromSession(session){ // Three input cases, three deterministic outcomes: // a) cold-load AND inflight both present → pick newer by ts so a // stale cold-load from the session GET cannot regress a fresher // INFLIGHT snapshot persisted from a still-running stream // (avoids visible rollback on reload). // b) only one of cold-load / inflight is present → use it. // c) neither → reset to empty + sentinel so loadTodos() falls // through to the legacy reverse-scan or paints the empty state. const sid=(session&&session.session_id)||''; const inflight=(typeof INFLIGHT==='object'&&INFLIGHT&&sid)?INFLIGHT[sid]:null; const cold=session&&session.todo_state; const coldOk=!!(cold&&Array.isArray(cold.todos)); const inflightOk=!!(inflight&&Array.isArray(inflight.todos)&&inflight.todoStateMeta); const coldTs=coldOk?(Number(cold.ts)||0):0; const inflightTs=inflightOk?(Number(inflight.todoStateMeta&&inflight.todoStateMeta.ts)||0):0; // Whether a live stream currently owns this session. This is the signal // that disambiguates a ts-less cold-load (see below); it comes from the // session GET payload (mirrors sessions.js `S.session.active_stream_id`). const streamActive=!!(session&&session.active_stream_id); if(coldOk&&inflightOk){ // Reconcile the server's settled cold-load snapshot against the // locally-persisted INFLIGHT snapshot. // // coldTs===0 means the cold-load carries NO usable timestamp, so we // cannot order it against INFLIGHT by recency. A todo tool message can // legitimately lose its `timestamp` during context compression/rebuild // (the on-disk message ends up timestamp=None), and derive_todo_state // (api/todo_state.py) then returns the correct latest-by-POSITION todos // but omits `ts`. The tie-break depends on who owns the INFLIGHT tail: // // - stream ACTIVE → INFLIGHT is the live tail. The most recent todo // write may still be in flight and not yet settled into the message // list derive_todo_state scans, so a ts-less cold-load can be an // OLDER (pre-latest-write) view. Letting cold win here rolls the // panel back to a stale list, and since the stream may have just // ended on that very write there is no guaranteed forward SSE event // to self-heal. So prefer INFLIGHT. If cold is in fact newer, the // reattach replay (sessions.js attachLiveStream, reconnecting) re- // emits the journaled `todo_state` events which reconcile forward by // ts, so any transient discrepancy corrects itself. // // - stream IDLE → INFLIGHT is leftover from a finished/crashed stream // (idle sessions purge it shortly after, sessions.js), and there is // no replay to correct anything. The settled cold-load is the // authoritative latest-by-position view, so prefer cold. This also // preserves the original fix for the "shows an old todo list" bug, // where a stale prior-turn INFLIGHT must not beat a ts-less cold-load. // // When coldTs>0 the original recency rule stands: strict ">", and on a // tie prefer INFLIGHT for the freshest in-tab edits. const coldWins=(coldTs===0)?(!streamActive):(coldTs>inflightTs); if(coldWins){ S.todos=cold.todos; S.todoStateMeta={ ts:coldTs, source:'cold-load', version:Number(cold.version)||1, }; }else{ S.todos=inflight.todos; S.todoStateMeta=inflight.todoStateMeta; } }else if(coldOk){ S.todos=cold.todos; S.todoStateMeta={ ts:coldTs, source:'cold-load', version:Number(cold.version)||1, }; }else if(inflightOk){ S.todos=inflight.todos; S.todoStateMeta=inflight.todoStateMeta; }else{ S.todos=[]; S.todoStateMeta=null; } _resetTodosRenderCache(); if(typeof scheduleTodosRefresh==='function') scheduleTodosRefresh(); } function snapshotLiveTurnHtmlForSession(sid){ // Keep the DOM snapshot memory-only. Persisted INFLIGHT state intentionally // stores structured stream state, not outerHTML, so a hard reload still uses // the safer flat replay path instead of reviving stale nodes/listeners. if(!sid||!INFLIGHT[sid]) return; const turn=$('liveAssistantTurn'); if(!turn) return; if(turn.dataset&&turn.dataset.sessionId&&turn.dataset.sessionId!==sid) return; INFLIGHT[sid].liveTurnHtml=turn.outerHTML; } function _liveAssistantSegmentTextLength(seg){ if(!seg) return 0; const body=seg.querySelector('.msg-body')||seg; return String(body.textContent||'').trim().length; } function _mergeRestoredLiveAssistantSegment(restored, existing){ if(!restored||!existing) return; const existingLive=existing.querySelector('[data-live-assistant="1"]'); if(!existingLive) return; const restoredLive=restored.querySelector('[data-live-assistant="1"]'); const existingLen=_liveAssistantSegmentTextLength(existingLive); const restoredLen=_liveAssistantSegmentTextLength(restoredLive); if(existingLen<=restoredLen) return; const replacement=existingLive.cloneNode(true); if(restoredLive){ restoredLive.replaceWith(replacement); return; } const blocks=_assistantTurnBlocks(restored); if(!blocks) return; const anchor=Array.from(blocks.children).filter(el=> el.matches('.tool-call-group,.tool-card-row,.agent-activity-thinking,.thinking-card-row,[data-live-assistant="1"]') ).pop(); if(anchor) anchor.insertAdjacentElement('afterend', replacement); else blocks.appendChild(replacement); } function restoreLiveTurnHtmlForSession(sid){ const inflight=INFLIGHT[sid]; if(!sid||!inflight||!inflight.liveTurnHtml) return false; const inner=$('msgInner'); if(!inner) return false; const template=document.createElement('template'); template.innerHTML=String(inflight.liveTurnHtml||'').trim(); const restored=template.content.firstElementChild; if(!restored) return false; restored.id='liveAssistantTurn'; if(S.session) restored.dataset.sessionId=S.session.session_id; const existing=$('liveAssistantTurn'); _mergeRestoredLiveAssistantSegment(restored, existing); if(existing) existing.replaceWith(restored); else inner.appendChild(restored); // Transparent Stream: liveTurnHtml is restored via template.innerHTML, which // drops the property-bound onclick/onkeydown handlers wired by // _wireTransparentHeaderToggle / _attachCopyButton / _syncTransparentEventControls / // _wireTransparentTurnToggle. The settled cache fast-path re-runs the rehydrate; // this active-session live-turn restore path must too, or row toggles, copy // buttons, expand/collapse, and the turn chevron silently stop working after a // session-switch/reconnect restore. (Codex trifecta finding C1.) if(typeof _rehydrateTransparentStreamDom==='function') _rehydrateTransparentStreamDom(restored); if(typeof normalizeLiveActivityGroupPlacement==='function') normalizeLiveActivityGroupPlacement(restored); if(typeof _dedupeLiveProcessedWorklogAnchors==='function') _dedupeLiveProcessedWorklogAnchors(restored); const liveGroup=restored.querySelector('.tool-call-group[data-live-tool-call-group="1"]'); if(liveGroup&&typeof _startActivityElapsedTimer==='function') _startActivityElapsedTimer(liveGroup); if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost(); requestAnimationFrame(()=>postProcessRenderedMessages(restored)); return true; } function markInflight(sid, streamId) { const payload=JSON.stringify({sid, streamId, ts: Date.now()}); try{ localStorage.setItem(INFLIGHT_KEY, payload); }catch(err){ if(!_isStorageQuotaError(err)) return; try{ localStorage.removeItem(INFLIGHT_STATE_KEY); localStorage.setItem(INFLIGHT_KEY, payload); }catch(_){} } } function clearInflight() { localStorage.removeItem(INFLIGHT_KEY); } function showReconnectBanner(msg) { $('reconnectMsg').textContent = msg || 'A response may have been in progress when you last left.'; $('reconnectBanner').classList.add('visible'); } function dismissReconnect() { $('reconnectBanner').classList.remove('visible'); clearInflight(); } // ── Live host resource health panel (#693) ── const SYSTEM_HEALTH_INTERVAL_MS=5000; let _systemHealthTimer=null; function _systemHealthPercent(metric){ const percent=Number(metric&&metric.percent); if(!Number.isFinite(percent)) return null; return Math.max(0,Math.min(100,Math.round(percent*10)/10)); } function _formatSystemHealthPercent(percent){ if(percent == null) return '—'; return `${percent.toFixed(percent%1?1:0)}%`; } function _formatSystemHealthBytes(metric){ if(!metric||!metric.used_bytes||!metric.total_bytes) return ''; const units=['B','KB','MB','GB','TB']; const fmt=(bytes)=>{ let value=Number(bytes)||0, idx=0; while(value>=1024&&idx=10||idx===0?0:1)} ${units[idx]}`; }; return `${fmt(metric.used_bytes)} / ${fmt(metric.total_bytes)}`; } function _updateSystemHealthMetric(name,metric){ const row=document.querySelector(`[data-system-health-metric="${name}"]`); if(!row) return; const rawPercent=_systemHealthPercent(metric); const percent=rawPercent == null ? 0 : rawPercent; const label=row.querySelector('[data-system-health-value]'); const bar=row.querySelector('.system-health-bar'); const fill=row.querySelector('.system-health-bar-fill'); const text=_formatSystemHealthPercent(rawPercent); if(label){ label.textContent=text; const bytes=(name==='memory'||name==='disk')?_formatSystemHealthBytes(metric):''; label.title=bytes||text; } if(bar) bar.setAttribute('aria-valuenow',String(percent)); if(fill) fill.style.width=`${percent}%`; } function setSystemHealthUnavailable(message){ const panel=$('systemHealthPanel'); const status=$('systemHealthStatus'); if(!panel) return; panel.classList.remove('loading'); panel.classList.add('unavailable'); if(status) status.textContent=message||'Unavailable'; ['cpu','memory','disk'].forEach(name=>_updateSystemHealthMetric(name,null)); } function renderSystemHealth(payload){ const panel=$('systemHealthPanel'); const status=$('systemHealthStatus'); if(!panel) return; if(!payload||payload.available===false){ setSystemHealthUnavailable('Unavailable'); return; } panel.classList.remove('loading','unavailable'); if(status) status.textContent=payload.status==='partial'?'Partial':'Live'; _updateSystemHealthMetric('cpu',payload.cpu); _updateSystemHealthMetric('memory',payload.memory); _updateSystemHealthMetric('disk',payload.disk); } async function pollSystemHealth(){ if(document.visibilityState !== 'visible') return; if(!_systemHealthPanelIsVisible()) return; try{ const payload=await api('/api/system/health',{timeoutToast:false}); renderSystemHealth(payload); }catch(_){ setSystemHealthUnavailable('Unavailable'); } } function _systemHealthPanelIsVisible(){ return document.visibilityState === 'visible' && !!document.querySelector('main.main.showing-insights') && !!$('systemHealthPanel'); } function startSystemHealthMonitor(){ if(!_systemHealthPanelIsVisible()) return; if(_systemHealthTimer) return; void pollSystemHealth(); _systemHealthTimer=setInterval(pollSystemHealth,SYSTEM_HEALTH_INTERVAL_MS); } function stopSystemHealthMonitor(){ if(_systemHealthTimer){clearInterval(_systemHealthTimer);_systemHealthTimer=null;} } function _syncSystemHealthMonitorVisibility(){ if(_systemHealthPanelIsVisible()) startSystemHealthMonitor(); else stopSystemHealthMonitor(); } document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility); if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',startSystemHealthMonitor); else startSystemHealthMonitor(); // ── Hermes agent/gateway heartbeat alert (#716) ── const AGENT_HEALTH_INTERVAL_MS=30000; const AGENT_HEALTH_DISMISSED_KEY='agent-health-dismissed'; let _agentHealthTimer=null; let _agentHealthLastState='unknown'; let _lastGatewayRestartTime=0; function _agentHealthDismissed(){ try{return localStorage.getItem(AGENT_HEALTH_DISMISSED_KEY)==='1';} catch(_){return false;} } function _setAgentHealthDismissed(value){ try{ if(value)localStorage.setItem(AGENT_HEALTH_DISMISSED_KEY,'1'); else localStorage.removeItem(AGENT_HEALTH_DISMISSED_KEY); }catch(_){ } } function _hideAgentHealthAlert(){ const banner=$('agentHealthBanner'); if(banner){banner.classList.remove('visible');banner.hidden=true;} } function _showAgentHealthAlert(payload){ if(_agentHealthDismissed()) return; const banner=$('agentHealthBanner'); const title=$('agentHealthTitle'); const details=$('agentHealthDetails'); if(!banner) return; if(title) title.textContent='Hermes agent is not responding'; const state=payload&&payload.details&&payload.details.gateway_state?` State: ${payload.details.gateway_state}.`:''; if(details) details.textContent=`Gateway heartbeat failed.${state} Messages may not be delivered until it comes back.`; banner.hidden=false; banner.classList.add('visible'); } function dismissAgentHealthAlert(){ _setAgentHealthDismissed(true); _hideAgentHealthAlert(); } async function restartGatewayService(){ const btn = $('btnRestartGateway'); const dismissBtn = $('agentHealthDismiss'); if(!btn) return; btn.disabled = true; if(dismissBtn) dismissBtn.disabled = true; const originalText = btn.textContent; btn.textContent = 'Restarting...'; try { const res = await api('/api/health/restart', {method: 'POST'}); if(res && res.ok){ showToast('Gateway service restarted successfully'); _hideAgentHealthAlert(); _lastGatewayRestartTime = Date.now(); setTimeout(pollAgentHealth, 15000); } else { showToast(res && res.error || 'Failed to restart gateway service'); } } catch(e) { showToast('Failed to restart gateway service: ' + e.message); } finally { btn.disabled = false; if(dismissBtn) dismissBtn.disabled = false; btn.textContent = originalText; } } async function pollAgentHealth(){ if(document.visibilityState !== 'visible') return; if(Date.now() - _lastGatewayRestartTime < 15000) return; try{ const payload=await api('/api/health/agent',{timeoutToast:false}); if(payload.alive === true){ _agentHealthLastState='alive'; _setAgentHealthDismissed(false); _hideAgentHealthAlert(); return; } if(payload.alive === false){ _agentHealthLastState='down'; _showAgentHealthAlert(payload); return; } if(payload.alive == null){ _agentHealthLastState='unknown'; _hideAgentHealthAlert(); } }catch(_){ _agentHealthLastState='unknown'; _hideAgentHealthAlert(); } } function startAgentHealthMonitor(){ if(document.visibilityState !== 'visible') return; if(_agentHealthTimer) return; void pollAgentHealth(); _agentHealthTimer=setInterval(pollAgentHealth, AGENT_HEALTH_INTERVAL_MS); } function stopAgentHealthMonitor(){ if(_agentHealthTimer){clearInterval(_agentHealthTimer);_agentHealthTimer=null;} } function _syncAgentHealthMonitorVisibility(){ if(document.visibilityState === 'visible') startAgentHealthMonitor(); else stopAgentHealthMonitor(); } document.addEventListener('visibilitychange',_syncAgentHealthMonitorVisibility); if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',startAgentHealthMonitor); else startAgentHealthMonitor(); async function refreshSession() { // When the banner is in post-update restart mode, the "Reload" button // should do a full page reload — a session refresh would just 502 while // the server is still restarting. if (window._restartingForUpdate) { location.reload(); return; } dismissReconnect(); if (!S.session) return; try { const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`); S.session = data.session; S.messages = data.session.messages || []; _messagesTruncated = !!data.session._messages_truncated; _oldestIdx = data.session._messages_offset || 0; const pendingMsg=getPendingSessionMessage(data.session,S.messages); if(pendingMsg) S.messages.push(pendingMsg); S.activeStreamId=data.session.active_stream_id||null; syncTopbar(); _renderMessagesWithScrollSnapshot(); showToast('Conversation refreshed'); } catch(e) { setStatus('Refresh failed: ' + e.message); } } // ── Update banner ── function _formatUpdateTargetStatus(label,info){ if(!info||info.no_git||!(info.behind>0)) return null; const release=(info.release_based&&info.latest_version) ?` (${info.current_version||'unknown'} -> ${info.latest_version})` :(info.branch?` (${info.branch})`:''); const noun=info.release_based?'release':'update'; return `${label}${release}: ${info.behind} ${noun}${info.behind>1?'s':''}`; } function _formatUpdateCheckError(label,info){ if(!info||!info.error) return null; const detail=String(info.error).replace(/^fetch failed:?\s*/i,'').trim(); return detail ? `${label}: ${detail}` : label; } function _isSafeUpdateCompareUrl(url){ if(!url||!/^https?:\/\//i.test(url)) return false; try{ const parsed=new URL(url); return parsed.protocol==='https:'||parsed.protocol==='http:'; }catch(e){ return false; } } function _updateCompareUrl(info){ if(!info) return null; const compareUrl=info.compare_url||null; if(compareUrl) return _isSafeUpdateCompareUrl(compareUrl)?compareUrl:null; const repo_url=info.repo_url; const currentSha=info.current_sha; const latestSha=info.latest_sha; if(!(repo_url&¤tSha&&latestSha)) return null; const fallbackUrl=repo_url+'/compare/'+currentSha+'...'+latestSha; return _isSafeUpdateCompareUrl(fallbackUrl)?fallbackUrl:null; } function _updateWhatsNewTargets(data){ const targets=[ {key:'webui',label:'WebUI',info:data&&data.webui}, {key:'agent',label:'Agent',info:data&&data.agent}, ]; return targets.map((target)=>({ key:target.key, label:target.label, info:target.info, url:_updateCompareUrl(target.info), })).filter((target)=>target.info&&target.info.behind>0&&target.url); } function _appendUpdateDiffLinks(container,targets,prefix){ if(!container) return; if(prefix) container.appendChild(document.createTextNode(prefix)); targets.forEach((target,idx)=>{ if(idx>0) container.appendChild(document.createTextNode(' \u00b7 ')); const link=document.createElement('a'); link.href=target.url; link.target='_blank'; link.rel='noopener'; link.style.color='var(--accent)'; link.style.textDecoration='underline'; link.textContent=target.label; container.appendChild(link); }); } function _hideUpdateSummaryPanel(){ const panel=$('updateSummaryPanel'); const text=$('updateSummaryText'); const links=$('updateSummaryDiffLinks'); if(panel) panel.style.display='none'; if(text) text.textContent=''; if(links){links.replaceChildren();links.style.display='none';} } const WHATS_NEW_SUMMARY_STORAGE_KEY='hermes-whats-new-generated-summaries'; function _loadStoredUpdateSummaries(){ window._whatsNewGeneratedSummaries=window._whatsNewGeneratedSummaries||{}; try{ const raw=sessionStorage.getItem(WHATS_NEW_SUMMARY_STORAGE_KEY); if(!raw) return window._whatsNewGeneratedSummaries; const stored=JSON.parse(raw); if(stored&&typeof stored==='object') window._whatsNewGeneratedSummaries=stored; }catch(_e){ try{sessionStorage.removeItem(WHATS_NEW_SUMMARY_STORAGE_KEY);}catch(_ignore){} } return window._whatsNewGeneratedSummaries; } function _persistGeneratedSummaries(){ try{sessionStorage.setItem(WHATS_NEW_SUMMARY_STORAGE_KEY,JSON.stringify(window._whatsNewGeneratedSummaries||{}));}catch(_e){} } function _pruneGeneratedSummaries(data){ const cache=_loadStoredUpdateSummaries(); const valid=new Set(_updateWhatsNewTargets(data||{}).map((target)=>target.key)); let changed=false; Object.keys(cache).forEach((key)=>{ if(!valid.has(key)){delete cache[key];changed=true;} }); if(changed) _persistGeneratedSummaries(); } function _updateSummarySignature(info){ if(!info) return ''; return [info.current_sha||'',info.latest_sha||'',info.behind||0,info.compare_url||''].join('|'); } function _updateSummaryButtonLabel(target,data){ const labels=target.key==='webui' ? {generate:'Generate WebUI update summary',view:'View generated WebUI update summary',regenerate:'Re-generate WebUI update summary'} : {generate:'Generate Agent update summary',view:'View generated Agent update summary',regenerate:'Re-generate Agent update summary'}; const cache=_loadStoredUpdateSummaries()[target.key]; const signature=_updateSummarySignature(data&&data[target.key]); if(cache&&cache.signature===signature&&cache.payload) return labels.view; if(cache&&cache.signature!==signature) return labels.regenerate; return labels.generate; } function _rememberGeneratedSummary(target,payload,data){ if(!target) return; window._whatsNewGeneratedSummaries=window._whatsNewGeneratedSummaries||{}; window._whatsNewGeneratedSummaries[target]={ signature:_updateSummarySignature(data&&data[target]), payload:payload, }; _persistGeneratedSummaries(); } function _renderUpdateSummaryPanel(payload,data,targetKey){ const panel=$('updateSummaryPanel'); const text=$('updateSummaryText'); const links=$('updateSummaryDiffLinks'); if(!panel||!text) return; panel.style.display='block'; const sections=Array.isArray(payload&&payload.summary_sections)?payload.summary_sections:null; text.replaceChildren(); if(sections&§ions.length){ const wrap=document.createElement('div'); wrap.id='updateSummarySections'; wrap.style.display='grid'; wrap.style.gap='8px'; sections.forEach((section)=>{ const block=document.createElement('section'); const title=document.createElement('div'); title.style.fontWeight='650'; title.style.marginBottom='3px'; title.textContent=section.title||'Summary'; block.appendChild(title); const ul=document.createElement('ul'); ul.style.margin='0'; ul.style.paddingLeft='18px'; (Array.isArray(section.items)?section.items:[]).forEach((item)=>{ const li=document.createElement('li'); li.textContent=String(item||'').trim(); if(li.textContent) ul.appendChild(li); }); if(!ul.children.length){ const li=document.createElement('li'); li.textContent='No summary details available.'; ul.appendChild(li); } block.appendChild(ul); wrap.appendChild(block); }); text.appendChild(wrap); }else{ text.textContent=(payload&&payload.summary)||payload||'No summary available.'; } const targets=_updateWhatsNewTargets(data||window._updateData||{}).filter((target)=>!targetKey||target.key===targetKey); if(links){ links.replaceChildren(); if(targets.length){ links.style.display='block'; _appendUpdateDiffLinks(links,targets,'Regular diff comparison: '); }else{ links.style.display='none'; } } } async function showWhatsNewSummary(target){ const data=window._updateData||{}; const scopedUpdates=target?{[target]:data[target]}:data; const cache=target?_loadStoredUpdateSummaries()[target]:null; const signature=target?_updateSummarySignature(data[target]):''; if(cache&&cache.signature===signature&&cache.payload){ _renderUpdateSummaryPanel(cache.payload,data,target); _renderUpdateWhatsNewLinks(data,{mode:'summary'}); return; } _renderUpdateSummaryPanel({summary:'Writing a simple summary…'},data,target); try{ const res=await api('/api/updates/summary',{method:'POST',body:JSON.stringify({updates:scopedUpdates,target:target||null}),timeoutMs:60000}); _rememberGeneratedSummary(target,res,data); _renderUpdateSummaryPanel(res,data,target); _renderUpdateWhatsNewLinks(data,{mode:'summary'}); }catch(e){ console.warn('[updates] summary failed',e); _renderUpdateSummaryPanel({ summary_sections:[ {title:"What you'll notice",items:['Could not generate the summary right now.']}, {title:'Worth knowing',items:['Try again later, or use the comparison links below for the raw update details.']}, ], },data,target); } } function _renderUpdateWhatsNewLinks(data){ const options=arguments.length>1&&arguments[1]?arguments[1]:{}; const container=$('updateWhatsNewLinks'); if(!container) return; container.replaceChildren(); const targets=_updateWhatsNewTargets(data); if(!targets.length){ container.style.display='none'; _hideUpdateSummaryPanel(); return; } container.style.display='block'; _pruneGeneratedSummaries(data); const useSummary=(options.mode||'')==='summary'||window._whatsNewSummaryEnabled===true; if(useSummary){ targets.forEach((target,idx)=>{ if(idx>0) container.appendChild(document.createTextNode(' \u00b7 ')); const btn=document.createElement('button'); btn.type='button'; btn.className='linklike'; btn.style.color='var(--accent)'; btn.style.textDecoration='underline'; btn.style.background='none'; btn.style.border='0'; btn.style.padding='0'; btn.style.cursor='pointer'; btn.textContent=_updateSummaryButtonLabel(target,data); btn.onclick=()=>showWhatsNewSummary(target.key); container.appendChild(btn); }); return; } _hideUpdateSummaryPanel(); if(targets.length===1){ const target=targets[0]; const link=document.createElement('a'); link.href=target.url; link.target='_blank'; link.rel='noopener'; link.style.color='var(--accent)'; link.style.textDecoration='underline'; link.textContent="What's new in "+target.label+'?'; container.appendChild(link); return; } _appendUpdateDiffLinks(container,targets,"What's new: "); } function _showUpdateBanner(data){ const parts=[]; const webuiPart=_formatUpdateTargetStatus('WebUI',data.webui); const agentPart=_formatUpdateTargetStatus('Agent',data.agent); if(webuiPart) parts.push(webuiPart); if(agentPart) parts.push(agentPart); window._updateData=data; if(!parts.length){ _renderUpdateWhatsNewLinks(data); const staleBanner=$('updateBanner'); if(staleBanner) staleBanner.classList.remove('visible'); return; } const msg=$('updateMsg'); if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available'; const banner=$('updateBanner'); if(banner) banner.classList.add('visible'); const summaryMode=window._whatsNewSummaryEnabled===true?'summary':'diff'; _renderUpdateWhatsNewLinks(data,{mode:summaryMode}); } function dismissUpdate(){ const b=$('updateBanner');if(b)b.classList.remove('visible'); sessionStorage.setItem('hermes-update-dismissed','1'); } function _isUpdateApplyNetworkError(error){ if(error && error.status) return false; const message=(error&&error.message)||String(error||''); return /Failed to fetch|NetworkError|Load failed/i.test(message); } function _formatUpdateApplyExceptionMessage(error){ if(_isUpdateApplyNetworkError(error)){ return 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.'; } const message=(error&&error.message)||String(error||'unknown error'); return 'Update failed: '+message; } async function applyUpdates(){ if(window._updateApplyInFlight) return; window._updateApplyInFlight=true; const btn=$('btnApplyUpdate'); const resetApplyButton=(delayMs)=>{ const reset=()=>{ window._updateApplyInFlight=false; if(btn){btn.disabled=false;btn.textContent='Update Now';} }; if(delayMs>0) setTimeout(reset,delayMs); else reset(); }; if(btn){btn.disabled=true;btn.textContent='Updating\u2026';} const errEl=$('updateError'); if(errEl){errEl.style.display='none';errEl.textContent='';} // Hide any leftover force-update button from a prior conflict so a fresh // retry starts clean (otherwise stale state points at the wrong target). const forceBtnReset=$('btnForceUpdate'); if(forceBtnReset){forceBtnReset.style.display='none';forceBtnReset.dataset.target='';} const targets=[]; if(window._updateData?.agent?.behind>0) targets.push('agent'); if(window._updateData?.webui?.behind>0) targets.push('webui'); if(!targets.length){ const msg='No update target selected. Refresh update status and retry.'; if(errEl){errEl.textContent=msg;errEl.style.display='block';} else showToast(msg,5000,'error'); resetApplyButton(0); return; } try{ const stashConflictMessages=[]; const baselineServerIdentity = await _readHealthServerIdentity(); for(const target of targets){ const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000}); if(!res.ok){ _showUpdateError(target,res); resetApplyButton(0); return; } if(res.stash_conflict){ stashConflictMessages.push('Update applied ('+target+'): '+(res.message||'Local changes were preserved in git stash.')); if(errEl){errEl.textContent=stashConflictMessages.join('\n\n');errEl.style.display='block';} } } const stashConflictMessage=stashConflictMessages.join('\n\n'); showToast(stashConflictMessage||'Update applied — restarting…',stashConflictMessages.length?10000:undefined,stashConflictMessages.length?'warning':undefined); sessionStorage.removeItem('hermes-update-checked'); sessionStorage.removeItem('hermes-update-dismissed'); _waitForServerThenReload({baselineServerIdentity}); }catch(e){ const msg=_formatUpdateApplyExceptionMessage(e); if(errEl){errEl.textContent=msg;errEl.style.display='block';} else showToast(msg); resetApplyButton(_isUpdateApplyNetworkError(e)?5000:0); } } function _showUpdateError(target,res){ const errEl=$('updateError'); const forceBtn=$('btnForceUpdate'); const msg='Update failed ('+target+'): '+(res.message||'unknown error'); if(errEl){ errEl.textContent=msg; errEl.style.display='block'; } else { showToast(msg); } // Show "Force update" button when the error is recoverable by a hard reset if(forceBtn&&(res.conflict||res.diverged)){ forceBtn.dataset.target=target; forceBtn.style.display='inline-block'; } } function _normalizeHealthServerIdentity(rawIdentity){ if(rawIdentity===undefined||rawIdentity===null) return null; if(typeof rawIdentity==='string'){ const value=rawIdentity.trim(); return value ? value : null; } const numeric=Number(rawIdentity); return Number.isFinite(numeric) ? String(numeric) : null; } function _healthResponseServerIdentity(data){ if(!data||typeof data!=='object') return null; const serverStartedAt=_normalizeHealthServerIdentity(data.server_started_at); const hasUptimeSeconds=data.uptime_seconds!==null&&data.uptime_seconds!==undefined; const uptimeSeconds=hasUptimeSeconds?Number(data.uptime_seconds):NaN; const normalizedUptime=Number.isFinite(uptimeSeconds)&&uptimeSeconds>=0 ? uptimeSeconds : null; if(serverStartedAt===null&&normalizedUptime===null) return null; return {serverStartedAt,uptimeSeconds:normalizedUptime}; } async function _readHealthServerIdentity() { try { const r=await fetch(new URL('health', document.baseURI||location.href).href,{cache:'no-store'}); if(!r.ok) return null; const data=await r.json(); return _healthResponseServerIdentity(data); } catch (_) { return null; } } async function forceUpdate(btn){ const target=btn&&btn.dataset.target; if(!target) return; const confirmed=await showConfirmDialog({ title:'Force update '+target+'?', message:'This will discard all local changes and delete untracked files in the '+target+' repo, then reset to the latest remote version. This cannot be undone.', confirmLabel:'Force update', danger:true, focusCancel:true, }); if(!confirmed) return; btn.disabled=true;btn.textContent='Force updating\u2026'; const errEl=$('updateError'); if(errEl){errEl.style.display='none';} try{ const baselineServerIdentity = await _readHealthServerIdentity(); const res=await api('/api/updates/force',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000}); if(!res.ok){ if(errEl){errEl.textContent='Force update failed: '+(res.message||'unknown error');errEl.style.display='block';} btn.disabled=false;btn.textContent='Force update'; return; } showToast('Force update applied — restarting…'); sessionStorage.removeItem('hermes-update-checked'); sessionStorage.removeItem('hermes-update-dismissed'); _waitForServerThenReload({baselineServerIdentity}); }catch(e){ if(errEl){errEl.textContent='Force update failed: '+e.message;errEl.style.display='block';} btn.disabled=false;btn.textContent='Force update'; } } // Poll /health after an update-triggered restart, then reload. Replaces the // blind setTimeout(reload, 2500) that race-lost against slow hardware or // reverse proxies that 502 immediately when the upstream socket closes (#874). async function _waitForServerThenReload(opts){ // Polls the /health endpoint; implementation uses a relative URL so subpath mounts keep working. opts=opts||{}; const interval=opts.interval||500; const maxMs=opts.maxMs||15000; const baselineServerIdentity=(()=>{ const rawIdentity=opts.baselineServerIdentity; if(!rawIdentity||typeof rawIdentity!=='object'){ const normalizedServerStartedAt=_normalizeHealthServerIdentity(rawIdentity); return normalizedServerStartedAt===null ? null : {serverStartedAt:normalizedServerStartedAt,uptimeSeconds:null}; } const normalizedIdentity={ serverStartedAt:_normalizeHealthServerIdentity(rawIdentity.serverStartedAt), uptimeSeconds:Number.isFinite(Number(rawIdentity.uptimeSeconds))&&Number(rawIdentity.uptimeSeconds)>=0 ? Number(rawIdentity.uptimeSeconds) : null, }; return normalizedIdentity.serverStartedAt===null&&normalizedIdentity.uptimeSeconds===null ? null : normalizedIdentity; })(); window._restartingForUpdate=true; const msgEl=$('reconnectMsg'); const banner=$('reconnectBanner'); if(msgEl) msgEl.textContent='⏳ Restarting… please wait'; if(banner) banner.classList.add('visible'); const deadline=Date.now()+maxMs; // Track restart-outage evidence. An outage (failed or non-OK /health probes) // followed by a healthy response is a reliable new-instance signal even when // only uptime_seconds is comparable and the replacement's uptime is not strictly // lower than the captured baseline (e.g. a deployment that strips // server_started_at and whose baseline uptime was very low). We require at least // TWO consecutive outage probes before trusting it, so a single transient network // blip (with the OLD process still up and its uptime merely increasing) cannot // trigger a premature reload onto the old server. Both thrown fetch errors AND // non-OK responses (e.g. a reverse-proxy 502/503 during restart) count as outage // evidence. (#3713 Codex catches) let _consecutiveOutages=0; const _restartOutageObserved=()=>_consecutiveOutages>=2; // Give the server a moment to actually begin its restart before the first // probe — otherwise the old process may still respond ok on the first poll. await new Promise(r=>setTimeout(r, interval)); while(Date.now()=2 consecutive failed/non-OK probes) before this healthy response: // treat that outage as the restart, so reload even though the // replacement uptime is not strictly lower than a very-low baseline. location.reload(); return; } // Healthy response still describing the pre-restart process: this is the // OLD server answering, so any earlier outage was a transient blip, not a // restart — reset the outage evidence so it can't accumulate into a false // positive across unrelated blips. _consecutiveOutages=0; // Keep polling while /health still describes the pre-restart process. }else{ // Reachable but not status:ok (still starting up) — counts as outage. _consecutiveOutages++; } }else{ // Non-OK HTTP (e.g. reverse-proxy 502/503 during restart) — outage evidence. _consecutiveOutages++; } }catch(_){ _consecutiveOutages++; /* socket closed during restart — retry */ } await new Promise(r=>setTimeout(r, interval)); } if(msgEl) msgEl.textContent='⚠️ Server is taking longer than expected — click Reload when ready'; } function _pendingCurrentTailUserMessage(messages){ const list=Array.isArray(messages)?messages:[]; for(let i=list.length-1;i>=0;i--){ const msg=list[i]; if(!msg) continue; if(String(msg.role||'')==='user') return msg; if(msg._live||String(msg.role||'')==='tool') continue; return null; } return null; } function getPendingSessionMessage(session, messagesOverride=null){ const text=String(session?.pending_user_message||'').trim(); if(!text) return null; const attachments=Array.isArray(session?.pending_attachments)?session.pending_attachments.filter(Boolean):[]; const sourceMessages=Array.isArray(messagesOverride)?messagesOverride:session?.messages; const messages=Array.isArray(sourceMessages)?sourceMessages:[]; const currentTailUser=_pendingCurrentTailUserMessage(messages); if(currentTailUser){ const pendingCandidate={role:'user',content:text}; const sameCurrentTurn=typeof _sameTranscriptMessage==='function' ? _sameTranscriptMessage(currentTailUser,pendingCandidate) : String(msgContent(currentTailUser)||'').trim()===text; if(sameCurrentTurn){ if(attachments.length&&!currentTailUser.attachments?.length) currentTailUser.attachments=attachments; return null; } } return { role:'user', content:text, attachments:attachments.length?attachments:undefined, _ts:session?.pending_started_at||Date.now()/1000, _pending:true, _source:session?.pending_user_source||undefined, }; } async function checkInflightOnBoot(sid) { const raw = localStorage.getItem(INFLIGHT_KEY); if (!raw) return; try { const {sid: inflightSid, streamId, ts} = JSON.parse(raw); if (inflightSid !== sid) { clearInflight(); return; } if (S.activeStreamId && S.activeStreamId === streamId) return; // Only show banner if the in-flight entry is less than 10 minutes old if (Date.now() - ts > 10 * 60 * 1000) { clearInflight(); return; } // Check if stream is still active const status = await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId || '')}`); if (status.active) { // Stream is genuinely still running -- show the banner showReconnectBanner(t('reconnect_active')); } else { // Stream finished. Only show banner if reload happened within 90 seconds // (longer gap = normal completed session, not a mid-stream reload) if (Date.now() - ts < 90 * 1000) { showReconnectBanner(t('reconnect_finished')); } else { clearInflight(); // completed normally, no banner needed } } } catch(e) { clearInflight(); } } function _topbarLoadedMessageCount(){ const messages=Array.isArray(S.messages)?S.messages:[]; return messages.filter(m=>m&&m.role&&m.role!=='tool').length; } function _topbarMessageMetaText(){ const loadedCount=_topbarLoadedMessageCount(); const totalCount=Number(S.session&&S.session.message_count); const hasTotal=Number.isFinite(totalCount)&&totalCount>0; const isTruncated=!!(typeof _messagesTruncated!=='undefined'&&_messagesTruncated); if(isTruncated&&hasTotal&&totalCount>loadedCount){ return `${loadedCount} loaded of ${totalCount} messages`; } // Fully loaded: use the tool-row-filtered loadedCount, NOT the raw server // total (api/routes.py sets message_count to len(_all_msgs), which counts // role:"tool" rows the topbar has always excluded). Only the truncated // branch above surfaces the raw server total, and only as "loaded of total". return t('n_messages',loadedCount); } function syncTopbar(){ if(!S.session){ document.title=assistantDisplayName(); if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays(); if(typeof _syncWorkspaceHeadingState==='function') _syncWorkspaceHeadingState(); if(typeof syncModelChip==='function') syncModelChip(); if(typeof syncTerminalButton==='function') syncTerminalButton(); if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions(); else { const sidebarName=$('sidebarWsName'); if(sidebarName && sidebarName.textContent==='Workspace'){ sidebarName.textContent=t('no_workspace'); } } if(typeof syncAppTitlebar==='function') syncAppTitlebar(); // Update profile chip even when no session is active (e.g. right after profile switch) const _profileLabel=$('profileChipLabel'); if(_profileLabel) _profileLabel.textContent=S.activeProfile||'default'; const _titleLabel=$('titlebarProfileLabel'); if(_titleLabel) _titleLabel.textContent=S.activeProfile||'default'; return; } const sessionTitle=S.session.title||t('untitled'); const _topbarTitle=$('topbarTitle');if(_topbarTitle)_topbarTitle.textContent=sessionTitle; document.title=sessionTitle+' \u2014 '+assistantDisplayName(); if(typeof activeSessionHasPendingPromptAttention==='function'&&activeSessionHasPendingPromptAttention()){ document.title='● '+document.title; } const _topbarMeta=$('topbarMeta'); if(_topbarMeta){ let sourceLabel=(S.session&&(S.session.source_label||S.session.source_tag||S.session.raw_source))||''; // Recovered sidecars stamp source_label 'WebUI' (api/session_recovery.py); don't badge a native session as its own source (#3338). if(/^webui$/i.test(sourceLabel)) sourceLabel=''; const metaText=_topbarMessageMetaText(); _topbarMeta.textContent=metaText; if(sourceLabel){ const badge=document.createElement('span'); badge.className='topbar-source-badge'; badge.textContent=sourceLabel+(S.session.read_only?' · read-only':''); _topbarMeta.appendChild(document.createTextNode(' ')); _topbarMeta.appendChild(badge); } } if(typeof syncAppTitlebar==='function') syncAppTitlebar(); if(typeof _syncWorkspaceHeadingState==='function') _syncWorkspaceHeadingState(); // If a profile switch just happened, apply its model rather than the session's stale value. // S._pendingProfileModel is set by switchToProfile() and cleared here after one application. const modelOverride=S._pendingProfileModel; let currentModel=S.session.model||''; if(modelOverride){ S._pendingProfileModel=null; const providerOverride=S._pendingProfileModelProvider||null; S._pendingProfileModelProvider=null; _applyModelToDropdown(modelOverride,$('modelSelect'),providerOverride); currentModel=modelOverride; } else { const modelSel=$('modelSelect'); const rawCurrentModel=String(currentModel||'').trim(); const hasSessionModel=rawCurrentModel&&rawCurrentModel.toLowerCase()!=='unknown'; if(!hasSessionModel){ // Missing/unknown session metadata must not leave the picker on the // previously viewed chat's model (#1771). Apply the configured default // first, then the first available option only as an HTML fallback. const fallback=_applySessionModelFallback(modelSel); if(fallback){ // Defer state mutation + network write while the live model resolution // is in flight — sessions.js sets _modelResolutionDeferred=true between // the fast-path session render and the resolve_model=1 round-trip. // Persisting here would race that resolution and would also issue // silent /api/session/update POSTs against imported/read-only CLI // sessions whose model field reads "unknown" (#1779 stage-310 review). // The visible sel.value change still happens above for UX; only the // state mutation + persist defers. const deferModelCorrection=Boolean(S.session._modelResolutionDeferred); if(!deferModelCorrection){ S.session.model=fallback.model; S.session.model_provider=fallback.model_provider||null; currentModel=fallback.model; _persistSessionModelCorrection(fallback.model,S.session.model_provider||null); } } } else { const applied=_applyModelToDropdown(currentModel,modelSel,S.session.model_provider||null); // If the session model is missing from the current provider list, inject // a session-scoped option instead of displaying the previous/static // selection. Only fall back if that repair path is unavailable. if(!applied){ const deferModelCorrection=Boolean(S.session._modelResolutionDeferred); const missingModelIsRoutable=_providerDefersMissingModelFallback(S.session.model_provider||window._activeProvider||null); // Also defer if a live model fetch is still in flight — the model may be // in the list once the fetch completes. Persisting now would corrupt the // session with the wrong model before live models arrive (#1169). const liveStillPending=window._activeProvider&&_liveModelFetchPending.has(window._activeProvider); if(liveStillPending||missingModelIsRoutable){ // Live fetch in flight — don't touch sel.value or S.session.model yet. // _addLiveModelsToSelect() will re-apply S.session.model once done (#1169). // Named custom providers/OpenRouter can also route vendor-prefixed IDs // outside the static catalog, so preserve the user's explicit choice. if(typeof _ensureModelOptionInDropdown==='function'){ const sessionOption=_ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null); if(sessionOption) currentModel=sessionOption; } } else { const sessionOption=(typeof _ensureModelOptionInDropdown==='function') ? _ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null) : null; if(sessionOption){ currentModel=sessionOption; } else { const fallback=_applySessionModelFallback(modelSel); if(fallback&&!deferModelCorrection){ S.session.model=fallback.model; S.session.model_provider=fallback.model_provider||null; currentModel=fallback.model; // Persist the correction so the session doesn't re-inject on next load. _persistSessionModelCorrection(fallback.model,S.session.model_provider||null); } } } } } } if(typeof syncModelChip==='function') syncModelChip(); if(typeof syncReasoningChip==='function') syncReasoningChip(); if(typeof syncToolsetsChip==='function') syncToolsetsChip(); // Show Clear button only when session has messages const clearBtn=$('btnClearConv'); if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none'; if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions(); if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays(); if(typeof syncTerminalButton==='function') syncTerminalButton(); // modelSelect already set above // Update profile chip label. // The chip is the profile-SWITCHER trigger (it fronts the profile dropdown) and // governs where the next message / new chat routes — both follow the client // active profile (the hermes_profile cookie, set only by /api/profile/switch). // It must therefore reflect S.activeProfile, NOT the loaded session's profile. // #3331 briefly keyed this on S.session.profile so the label would track the // session being browsed, but loadSession() never updates S.activeProfile, so // opening a cross-profile session made the chip disagree with the dropdown // checkmark and lie about message routing (#3635). #3331's legitimate work — // scoping project/session operations to the session's own profile — is // unaffected by this line. const profileLabel=$('profileChipLabel'); if(profileLabel) profileLabel.textContent=S.activeProfile||'default'; const titleLabel=$('titlebarProfileLabel'); if(titleLabel) titleLabel.textContent=S.activeProfile||'default'; } function msgContent(m){ // Extract plain text content from a message for filtering let c=m.content||''; if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('').trim(); return String(c).trim(); } function _isRecoveryControlMessageText(text){ const normalized=String(text||'').replace(/\s+/g,' ').trim(); if(!normalized) return false; const systemRecovery=/^\[System:/i.test(normalized) && (/continue exactly where you left off/i.test(normalized) || /do not retry the same tool call/i.test(normalized)); const backendRecovery=/^the live worker stopped before this run finished\.?$/i.test(normalized); return !!(systemRecovery || backendRecovery); } function _isRecoveryControlMessage(m){ if(!m||m.role==='tool') return false; if(m.recovery_control===true) return true; // Backward-compat ONLY: strict fully-anchored text match for pre-marker // persisted sessions. NOT provider_details_label — a real "Response // interrupted" card carries 'Interruption details' and must stay visible. return _isRecoveryControlMessageText(msgContent(m)||String(m.content||'')); } function _assistantAnchorSceneFinalAnswerText(m){ const scene=m&&m._anchor_activity_scene&&typeof m._anchor_activity_scene==='object' ? m._anchor_activity_scene : null; const text=scene&&typeof scene.final_answer==='string'?scene.final_answer:''; return String(text||'').trim()?text:''; } function _assistantMessageHasVisibleContent(m){ if(!m||m.role!=='assistant') return false; if(_isRecoveryControlMessage(m)) return false; if(_assistantAnchorSceneFinalAnswerText(m)) return true; const content=m.content; if(typeof content==='string') return !_isAssistantEmptyPlaceholderContent(m, content)&&!!content.trim(); if(!Array.isArray(content)) return false; return content.some(part=>{ if(typeof part==='string') return !!part.trim(); if(!part||typeof part!=='object') return false; if(part.type==='text'||part.type==='input_text'||part.type==='output_text'){ return !!String(part.text||part.content||'').trim(); } return false; }); } function _fmtDateSep(d){ const todayStart=new Date();todayStart.setHours(0,0,0,0); const dStart=new Date(d);dStart.setHours(0,0,0,0); const diffDays=Math.round((todayStart-dStart)/86400000); if(diffDays===0) return 'Today'; if(diffDays===1) return 'Yesterday'; if(diffDays>0 && diffDays<7) return dStart.toLocaleDateString([], {weekday:'long'}); const opts={month:'short', day:'numeric'}; if(todayStart.getFullYear()!==dStart.getFullYear()) opts.year='numeric'; return dStart.toLocaleDateString([], opts); } const _ERR_MSG_RE=/^(?:\*\*error\b|error:|connection lost|no response received)/i; function _messageHasReasoningPayload(m){ if(!m||m.role!=='assistant') return false; if(m.reasoning||m.reasoning_content||m.thinking||m._reasoning) return true; if(Array.isArray(m.content)) return m.content.some(p=>p&&(p.type==='thinking'||p.type==='reasoning')); if(typeof window!=='undefined'&&typeof window._extractInlineThinkingFromContentForRender==='function'){ const split=window._extractInlineThinkingFromContentForRender(String(m.content||''),''); return !!(split&&split.reasoning); } return /^\s*(?:[\s\S]*?<\/think>|<\|channel\|?>thought\n?[\s\S]*?|<\|turn\|>thinking\n[\s\S]*?)/.test(String(m.content||'')); } function _isAssistantEmptyPlaceholderContent(m, content){ if(!m||m.role!=='assistant') return false; if(String(content||'').trim()!=='(empty)') return false; return _messageHasReasoningPayload(m); } function _formatTurnTps(value){ const n=Number(value); if(!Number.isFinite(n)||n<=0) return ''; const fixed=n>=100?Math.round(n).toLocaleString():n>=10?n.toFixed(1):n.toFixed(1); return `${fixed} t/s`; } function isTpsDisplayEnabled(){ return window._showTps===true; } function _assistantRoleHtml(tsTitle='', tpsText=''){ const _bn=assistantDisplayName(); const tps=(isTpsDisplayEnabled()&&tpsText)?`${esc(tpsText)}`:''; return `
${esc(_bn.charAt(0).toUpperCase())}
${esc(_bn)}${tps}
`; } function _setAssistantTurnTps(turn, tpsText=''){ if(!turn) return; const role=turn.querySelector('.msg-role.assistant'); if(!role) return; let chip=role.querySelector('.msg-tps-inline'); const text=String(tpsText||'').trim(); if(!text){if(chip) chip.remove();return;} if(!chip){ chip=document.createElement('span'); chip.className='msg-tps-inline'; chip.title='Tokens per second'; role.appendChild(chip); } chip.textContent=text; } function _setLiveAssistantTps(value){ _setAssistantTurnTps($('liveAssistantTurn'), isTpsDisplayEnabled()?_formatTurnTps(value):''); } function _createAssistantTurn(tsTitle='', tpsText=''){ const row=document.createElement('div'); row.className='msg-row assistant-turn'; row.dataset.role='assistant'; if(S.session) row.dataset.sessionId=S.session.session_id; row.innerHTML=`${_assistantRoleHtml(tsTitle, tpsText)}
`; return row; } function _assistantTurnBlocks(turn){ return turn?turn.querySelector('.assistant-turn-blocks'):null; } function _assistantMessageBelongsInWorklog(m, rawIdx, toolCallAssistantIdxs, visibleContent, opts){ if(!m||m.role!=='assistant') return false; if(m._error) return false; const isTurnFinalAssistant=!!(opts&&opts.isTurnFinalAssistant); const visibleText=String(visibleContent!==undefined?visibleContent:msgContent(m)||'').trim(); const hasVisibleText=!!visibleText&&!_isAssistantEmptyPlaceholderContent(m, visibleText); if(m._live) return true; if(hasVisibleText&&m._anchor_activity_scene) return false; if(hasVisibleText&&isTurnFinalAssistant) return false; if(m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined) return true; const hasToolMetadata=!!( (toolCallAssistantIdxs&&toolCallAssistantIdxs.has(rawIdx))|| (Array.isArray(m.tool_calls)&&m.tool_calls.length)|| (Array.isArray(m.content)&&m.content.some(p=>p&&typeof p==='object'&&p.type==='tool_use')) ); if(hasVisibleText) return false; if(hasToolMetadata) return true; return false; } function _assistantThinkingBelongsInWorklog(m, rawIdx, toolCallAssistantIdxs){ return !!_assistantReasoningPayloadText(m)||_assistantMessageBelongsInWorklog(m, rawIdx, toolCallAssistantIdxs); } function _assistantReasoningPayloadText(m){ if(!m||m.role!=='assistant') return ''; const direct=m.reasoning_content||m.reasoning||m.thinking||m._reasoning||''; if(String(direct||'').trim()) return String(direct).trim(); if(Array.isArray(m.content)){ const parts=m.content .filter(p=>p&&typeof p==='object'&&(p.type==='thinking'||p.type==='reasoning')) .map(p=>p.text||p.content||'') .filter(text=>String(text||'').trim()); return parts.join('\n').trim(); } const text=String(m.content||''); if(typeof window!=='undefined'&&typeof window._extractInlineThinkingFromContentForRender==='function'){ const split=window._extractInlineThinkingFromContentForRender(text,''); if(split&&String(split.reasoning||'').trim()) return String(split.reasoning).trim(); } // Extract a LEADING thinking block even when visible answer text follows it // (e.g. "…4"). The matching display-content stripper // (_stripLeadingAssistantThinkingMarkup) is non-anchored, so the extractor must // be too — a trailing `$` anchor here dropped the reasoning whenever the turn // also had a visible answer, hiding the Thinking card entirely (#3401 regression // vs master, which used the non-anchored form). (#3709/#3592 family) const thinkMatch=text.match(/^\s*([\s\S]*?)<\/think>\s*/); if(thinkMatch) return thinkMatch[1].trim(); const thoughtMatch=text.match(/^\s*<\|channel\|?>thought\n?([\s\S]*?)\s*/); if(thoughtMatch) return thoughtMatch[1].trim(); const turnMatch=text.match(/^\s*<\|turn\|>thinking\n([\s\S]*?)\s*/); if(turnMatch) return turnMatch[1].trim(); return ''; } function _stripLeadingAssistantThinkingMarkup(content){ let out=String(content||''); const thinkMatch=out.match(/^\s*([\s\S]*?)<\/think>\s*/); if(thinkMatch) out=out.replace(/^\s*[\s\S]*?<\/think>\s*/,'').trimStart(); const thoughtMatch=out.match(/^\s*<\|channel\|?>thought\n?([\s\S]*?)\s*/); if(thoughtMatch) out=out.replace(/^\s*<\|channel\|?>thought\n?[\s\S]*?\s*/,'').trimStart(); const turnMatch=out.match(/^\s*<\|turn\|>thinking\n([\s\S]*?)\s*/); if(turnMatch) out=out.replace(/^\s*<\|turn\|>thinking\n[\s\S]*?\s*/,'').trimStart(); return out; } function _assistantVisibleContentForReasoningCompare(m){ if(!m||m.role!=='assistant') return ''; const anchorFinal=_assistantAnchorSceneFinalAnswerText(m); if(anchorFinal) return anchorFinal; let content=m.content||''; if(Array.isArray(content)){ content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n'); } if(typeof content==='string'){ if(typeof window!=='undefined'&&typeof window._extractInlineThinkingFromContentForRender==='function'){ const split=window._extractInlineThinkingFromContentForRender(content,''); content=split&&typeof split.content==='string'?split.content:_stripLeadingAssistantThinkingMarkup(content); } else { content=_stripLeadingAssistantThinkingMarkup(content); } } if(_isMarkerOnlyAssistantCompressionMessage(m)){ content='**Error:** No response received after context compression. Please retry.'; } if(_isAssistantEmptyPlaceholderContent(m, content)) return ''; return String(content||''); } function _assistantTurnFinalVisibleContentMap(visWithIdx){ const out=new Map(); let runIdxs=[]; let finalVisible=''; const flush=()=>{ for(const idx of runIdxs) out.set(idx, finalVisible); runIdxs=[]; finalVisible=''; }; for(const entry of visWithIdx||[]){ const m=entry&&entry.m; if(m&&m.role==='assistant'){ runIdxs.push(entry.rawIdx); const visible=_assistantVisibleContentForReasoningCompare(m); if(String(visible||'').trim()) finalVisible=visible; }else{ flush(); } } flush(); return out; } function _assistantTurnVisibleContentMap(visWithIdx){ const out=new Map(); let runIdxs=[]; let visibleTexts=[]; const flush=()=>{ for(const idx of runIdxs) out.set(idx, visibleTexts.slice()); runIdxs=[]; visibleTexts=[]; }; for(const entry of visWithIdx||[]){ const m=entry&&entry.m; if(m&&m.role==='assistant'){ runIdxs.push(entry.rawIdx); const visible=_assistantVisibleContentForReasoningCompare(m); if(String(visible||'').trim()) visibleTexts.push(visible); }else{ flush(); } } flush(); return out; } function _worklogReasoningTextFromMessage(m, rawIdx, toolCallAssistantIdxs, visibleContent, turnFinalVisibleContent, turnVisibleContents){ const thinkingText=_assistantReasoningPayloadText(m); const visibleTexts=Array.isArray(turnVisibleContents)?turnVisibleContents:[]; return _stripVisibleAssistantEchoFromThinking(thinkingText, visibleContent, turnFinalVisibleContent, ...visibleTexts); } function _worklogDetailsExpandedDefault(){ return window._worklogDetailsExpandedByDefault===true; } function _applyWorklogDetailsExpandedDefault(root){ const scope=root&&root.querySelectorAll?root:document; const open=_worklogDetailsExpandedDefault(); scope.querySelectorAll('.thinking-card').forEach(card=>{ card.classList.toggle('open', open); }); scope.querySelectorAll('.tool-group[data-tool-worklog-tool-group="1"],.tool-worklog-tool-group').forEach(group=>{ group.classList.toggle('open', open); group.classList.toggle('tool-worklog-tool-group-collapsed', !open); const summary=group.querySelector('.tool-group-head,.tool-worklog-tool-group-head'); if(summary) summary.setAttribute('aria-expanded', String(open)); }); } const _worklogDetailDisclosureSelector='.thinking-card,.tool-card,.tool-group[data-tool-worklog-tool-group="1"],.tool-worklog-tool-group'; function _worklogDetailTextKey(text, maxLen){ return String(text||'').replace(/\s+/g,' ').trim().slice(0,maxLen||160); } function _worklogDetailHashKey(value){ const s=String(value||''); let hash=2166136261; for(let i=0;i>>0; } return hash.toString(36); } function _worklogDetailBaseKey(el){ if(!el||!el.classList) return ''; const activity=el.closest&&el.closest('.agent-activity-group,.tool-worklog-group[data-tool-worklog-group="1"],.tool-call-group[data-tool-call-group="1"],.live-worklog[data-live-worklog-shell="1"]'); const scope=activity?[ activity.getAttribute('data-anchor-stream-id')?`stream:${activity.getAttribute('data-anchor-stream-id')}`:'', activity.getAttribute('data-activity-disclosure-key')||'', activity.getAttribute('data-tool-worklog-key')||'', activity.getAttribute('data-live-segment-seq')||'', activity.getAttribute('data-activity-burst-id')||'', ].filter(Boolean).join('|'):''; if(el.classList.contains('thinking-card')){ const row=el.closest('.agent-activity-thinking,.thinking-card-row'); const stable=row&&( row.getAttribute('data-thinking-key')|| row.getAttribute('data-live-thinking-key')|| row.getAttribute('data-live-segment-seq')|| row.getAttribute('data-activity-burst-id')|| row.id|| '' ); return `thinking:${scope}:${stable||'ordinal'}`; } if(el.classList.contains('tool-card')){ const row=el.closest('.tool-card-row'); const tid=row&&( row.getAttribute('data-tool-disclosure-key')|| row.getAttribute('data-live-tid')|| row.getAttribute('data-tool-call-id')|| row.getAttribute('data-tool-id')|| '' ); const label=row&&(row.dataset&&row.dataset.toolActionLabel)||''; const name=el.querySelector('.tool-card-name'); return `tool:${scope}:${tid||label||_worklogDetailTextKey(name?name.textContent:'tool',80)}`; } if(el.matches&&el.matches('.tool-group[data-tool-worklog-tool-group="1"],.tool-worklog-tool-group')){ const stable= el.getAttribute('data-tool-group-disclosure-key')|| el.getAttribute('data-activity-disclosure-key')|| el.getAttribute('data-tool-worklog-key')|| el.getAttribute('data-live-segment-seq')|| el.getAttribute('data-activity-burst-id')|| 'group'; return `tool-group:${scope}:${stable}`; } return ''; } function _worklogDetailDisclosureIsOpen(el){ return !!(el&&el.classList&&el.classList.contains('open')); } function _worklogDetailScrollableBody(el){ if(!el||!el.querySelector) return null; return el.querySelector('.thinking-card-body,.tool-card-detail'); } function _setWorklogDetailDisclosureOpen(el, open){ if(!el||!el.classList) return; el.classList.toggle('open', !!open); if(el.matches&&el.matches('.tool-group[data-tool-worklog-tool-group="1"],.tool-worklog-tool-group')){ el.classList.toggle('tool-worklog-tool-group-collapsed', !open); const summary=el.querySelector('.tool-group-head,.tool-worklog-tool-group-head'); if(summary) summary.setAttribute('aria-expanded', String(!!open)); } } function _worklogDetailDisclosureKeyForElement(el, counts){ const base=_worklogDetailBaseKey(el); if(!base) return ''; const idx=counts[base]||0; counts[base]=idx+1; return `${base}#${idx}`; } function _captureWorklogDetailDisclosureState(root){ const state=new Map(); if(!root||!root.querySelectorAll) return state; // Stamp the capturing session so a restore can't replay one session's // disclosure state onto another. Cross-session switches currently wipe // #msgInner (sessions.js loading placeholder) so the capture is normally // empty, but the ordinal/derived keys carry no session id — this stamp makes // the isolation explicit instead of depending on that wipe invariant. (Opus #4063.) try{ state._sid=S.session?S.session.session_id:null; }catch(_){ state._sid=null; } const counts=Object.create(null); root.querySelectorAll(_worklogDetailDisclosureSelector).forEach(el=>{ const key=_worklogDetailDisclosureKeyForElement(el, counts); if(!key) return; const body=_worklogDetailScrollableBody(el); state.set(key,{ open:_worklogDetailDisclosureIsOpen(el), scrollTop:body?Math.max(0,Number(body.scrollTop)||0):0, }); }); return state; } function _restoreWorklogDetailDisclosureState(root, state){ if(!root||!root.querySelectorAll||!state||!state.size) return; // Don't restore a snapshot captured under a different session. try{ if(state._sid!==undefined && state._sid!==(S.session?S.session.session_id:null)) return; }catch(_){ /* fall through */ } const counts=Object.create(null); root.querySelectorAll(_worklogDetailDisclosureSelector).forEach(el=>{ const key=_worklogDetailDisclosureKeyForElement(el, counts); if(!key||!state.has(key)) return; const saved=state.get(key); const open=(saved&&typeof saved==='object'&&'open' in saved)?saved.open:saved; _setWorklogDetailDisclosureOpen(el, open); const scrollTop=(saved&&typeof saved==='object')?Number(saved.scrollTop):0; if(open&&Number.isFinite(scrollTop)&&scrollTop>0){ const body=_worklogDetailScrollableBody(el); if(body) body.scrollTop=Math.min(scrollTop, Math.max(0, body.scrollHeight-body.clientHeight)); } }); } function _thinkingCardHtml(text, open){ const clean=_sanitizeThinkingDisplayText(text); const copyBtn=``; const shouldOpen=!!open||_worklogDetailsExpandedDefault(); const classes=`thinking-card${shouldOpen?' open':''}`; return `
${li('lightbulb',14)}${t('thinking')}${copyBtn}${li('chevron-right',12)}
${esc(clean)}
`; } function isSimplifiedToolCalling(){ return window._simplifiedToolCalling!==false; } function _thinkingActivityNode(text, open, disclosureKey){ const row=document.createElement('div'); row.className='agent-activity-thinking'; row.setAttribute('data-worklog-thinking-card','1'); if(disclosureKey) row.setAttribute('data-thinking-key', String(disclosureKey)); row.innerHTML=_thinkingCardHtml(text, open); _renderThinkingInto(row,text); return row; } function chatActivityMode(){ if(typeof window==='undefined') return 'compact_worklog'; return window._chatActivityDisplayMode || (window._transparentStream ? 'transparent_stream' : 'compact_worklog') || 'compact_worklog'; } function isTransparentStream(){ return chatActivityMode()==='transparent_stream'; } function isCompactWorklogMode(){ return isSimplifiedToolCalling()&&!isTransparentStream(); } if(typeof window!=='undefined'){ window.chatActivityMode=chatActivityMode; window.isTransparentStream=isTransparentStream; window.isCompactWorklogMode=isCompactWorklogMode; } function _toolShortName(name){ const raw=String(name||'').trim(); if(!raw) return 'tool'; if(raw.startsWith('mcp__')){ const parts=raw.split('__').filter(Boolean); if(parts.length>1) return parts.slice(1).join('/'); } if(raw.startsWith('mcp.')){ const parts=raw.split('.').filter(Boolean); if(parts.length>1) return parts.slice(1).join('/'); } return raw; } function _transparentEventPreview(text){ const clean=_sanitizeThinkingDisplayText(String(text||'')).replace(/\s+/g,' ').trim(); if(!clean) return ''; return clean.length>180?`${clean.slice(0,177)}...`:clean; } function _transparentToolStatus(tc, settled){ if(tc&&tc.is_error) return 'Failed'; if(tc&&tc.done===false) return settled?'Interrupted':'Running'; return 'Completed'; } // Quiet one-line summary for a collapsed transparent tool row (#4658). // The transparent view overrides the row name to the bare tool name // (_toolShortName, e.g. "read_file"/"terminal"), so — unlike the worklog view — // it has no action-label carrying the target, and buildToolCard's collapsed // preview is blanked for the common arg/shell case (the #4411 suppression that // assumes the name carries the target). That left transparent rows showing only // the bare tool name with no hint of what each call did. Rebuild a summary from // the call's TARGET (path/command/query/skill/...) — NOT the raw result JSON — // so it stays consistent with the "keep collapsed previews quiet" intent // (test_tool_card_preview_summary.py) while restoring "understand the call // without expanding it". function _transparentToolSummary(tc){ if(!tc||typeof tc!=='object') return ''; // Explicit progress text (e.g. subagent_progress) wins while still running. const explicit=String(tc.preview||'').trim(); if(tc.done===false&&explicit) return _shortToolLabel(explicit,160); // Target-based summary only (path/command/query/skill). Deliberately NO generic // arg-preview fallback: a call with args but no real target (e.g. `terminal` // with only {workdir} or an unknown tool with {mode:"dry-run"}) must yield an // EMPTY collapsed preview rather than dumping a raw arg snippet — that keeps the // collapsed row quiet and consistent with the no-args case (#4658 review). const target=typeof _toolVisibleTargetLabel==='function'?_toolVisibleTargetLabel(tc,{limit:160}):''; if(target) return target; return ''; } function _copyEventToClipboard(row){ if(!row) return; const type=row.getAttribute('data-event-type'); let text=''; let label='event'; if(type==='tool'){ const tc=row._tcData||{}; const fallbackName=row.getAttribute('data-event-name')||row.getAttribute('data-tool-name')||'tool'; label=`tool ${tc.name||fallbackName}`; const parts=[`tool: ${tc.name||fallbackName}`]; if(tc.args&&Object.keys(tc.args).length){ // Redact secret-bearing arg values before copying to clipboard, mirroring // the Full-tab render — content args can be long commands with secrets // past the first line (#4928 gate). let argsForCopy=tc.args; if(typeof _redactToolTargetLabel==='function'){ try{ argsForCopy={}; Object.entries(tc.args).forEach(([k,v])=>{ argsForCopy[k]=typeof v==='string'?_redactToolTargetLabel(v):v; }); }catch(e){ argsForCopy=tc.args; } } parts.push('args: '+JSON.stringify(argsForCopy,null,2)); } if(tc.snippet) parts.push('output:\n'+String(tc.snippet)); if(parts.length===1){ const argsText=Array.from(row.querySelectorAll('.tool-card-args .tool-arg-pair')) .map(pair=>String(pair.textContent||'').trim()) .filter(Boolean) .join('\n'); const outputText=String((row.querySelector('.tool-card-result pre')||{}).textContent||'').trim(); if(argsText) parts.push('args:\n'+argsText); if(outputText) parts.push('output:\n'+outputText); } text=parts.join('\n'); }else if(type==='thinking'){ const pre=row.querySelector('.thinking-card-body pre'); text=pre?pre.textContent:(row.textContent||'').replace(/^\s*Thinking\s*/i,''); label='thinking'; }else{ text=row.textContent||''; } const fallback=()=>{ try{ const ta=document.createElement('textarea'); ta.value=text; ta.setAttribute('readonly',''); ta.style.position='absolute'; ta.style.left='-9999px'; document.body.appendChild(ta); ta.select(); const ok=document.execCommand('copy'); document.body.removeChild(ta); if(typeof showToast==='function') showToast(ok?(t('copied')||'Copied'):(t('copy_failed')||'Copy failed'),1600); }catch(_){ if(typeof showToast==='function') showToast(t('copy_failed')||'Copy failed',2000,'error'); } }; if(navigator&&navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(text).then(()=>{ if(typeof showToast==='function') showToast(`${t('copied')||'Copied'} ${label}`,1600); }).catch(fallback); }else{ fallback(); } } function _attachCopyButton(header){ if(!header) return null; const bindCopyButton=(btn)=>{ if(!btn) return null; btn.classList.add('transparent-event-copy'); btn.setAttribute('role','button'); btn.setAttribute('tabindex','0'); btn.setAttribute('aria-label',t('copy')||'Copy'); btn.setAttribute('data-transparent-copy','1'); btn.title=t('copy')||'Copy'; const handler=function(ev){ ev.stopPropagation(); ev.preventDefault(); _copyEventToClipboard(header.closest('.transparent-event-row')); }; btn.onclick=handler; btn.onkeydown=function(ev){ if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();handler(ev);} }; return btn; }; // Reuse ANY existing copy button (handles both .transparent-event-copy // added by this function AND the legacy .thinking-copy-btn baked into the // thinking-card template). Returning the existing one prevents the // duplicate copy buttons that appeared in thinking boxes. const existing=header.querySelector('.transparent-event-copy,.thinking-copy-btn'); if(existing){ // Normalise the class so CSS treats them identically. return bindCopyButton(existing); } const btn=document.createElement('span'); btn.className='transparent-event-copy'; btn.innerHTML=``; bindCopyButton(btn); // Place the copy button at a FIXED flexbox position regardless of // whether a toggle or status badge is present: always right before // the toggle. The CSS uses flexbox order to keep it visually stable // even if other elements are inserted between header and toggle. const toggle=header.querySelector('.tool-card-toggle,.thinking-card-toggle'); if(toggle&&toggle.parentNode===header) header.insertBefore(btn,toggle); else header.appendChild(btn); return btn; } function _transparentEventCountLabel(toolCount){ return toolCount?`Trace: ${toolCount} ${toolCount===1?'tool':'tools'}`:'Trace'; } function _setTransparentDetailMode(tab, mode){ const row=tab&&tab.closest?tab.closest('.transparent-event-row'):null; const detail=row&&row.querySelector('.tool-card-detail'); if(!detail) return; const next=mode==='output'?'output':'full'; detail.setAttribute('data-transparent-detail-mode',next); detail.querySelectorAll('.transparent-detail-mode').forEach(el=>{ el.classList.toggle('active', el===tab || el.getAttribute('data-mode')===next); }); } function _setTransparentCardOpen(card, open){ if(!card) return; const expanded=!!open; card.classList.toggle('open',expanded); const row=card.closest&&card.closest('.transparent-event-row'); if(row) row.setAttribute('data-expanded',expanded?'1':'0'); const header=card.querySelector('.tool-card-header,.thinking-card-header'); if(header) header.setAttribute('aria-expanded',expanded?'true':'false'); } function _wireTransparentHeaderToggle(header){ if(!header) return; header.setAttribute('data-transparent-toggle-bound','1'); header.onclick=function(ev){ const target=ev&&ev.target; if(target&&target.closest&&target.closest('.transparent-event-copy,.transparent-detail-mode,.tool-card-more')) return; const card=this.closest('.tool-card,.thinking-card'); _setTransparentCardOpen(card,!(card&&card.classList.contains('open'))); }; header.onkeydown=function(ev){ if(ev.key!=='Enter'&&ev.key!==' ') return; ev.preventDefault(); const card=this.closest('.tool-card,.thinking-card'); _setTransparentCardOpen(card,!(card&&card.classList.contains('open'))); }; header.setAttribute('role','button'); header.setAttribute('tabindex','0'); } function _transparentToolDetailHtml(tc, status){ const args=tc&&tc.args&&typeof tc.args==='object'?tc.args:{}; const argEntries=Object.entries(args); // The tool name is already shown in the row header and the status is shown as // a badge, so don't repeat them as pseudo-args in the body. Only surface a // duration meta when present. (Trifecta finding V6 — reduce redundancy.) const meta=[]; if(tc&&tc.duration!==undefined&&tc.duration!==null) meta.push(['duration', String(tc.duration)]); const preview=String((tc&&(tc.snippet||tc.preview||tc.result||tc.output))||'').trim(); const argHtml=[...meta,...argEntries].map(([k,v])=>{ let sv=typeof v==='string'?v:JSON.stringify(v,null,2); // Redact secret-bearing arg values before rendering the transparent Full // tab — content args can be long multi-line commands (#4928) whose later // lines may carry secrets the short label never showed (#4928 gate). if(typeof _redactToolTargetLabel==='function'){ try{ sv=_redactToolTargetLabel(sv); }catch(e){} } return `
${esc(String(k))}${esc(sv)}
`; }).join(''); return `
FullOutput
${argHtml}
${preview?`
${esc(preview)}
`:''}
`; } function _syncTransparentEventControls(turn){ if(!turn||!isTransparentStream()) return; const blocks=_assistantTurnBlocks(turn); if(!blocks) return; const rows=Array.from(blocks.querySelectorAll(':scope > .transparent-event-row,[data-transparent-event-row="1"]')); const toolCount=rows.filter(row=>row.getAttribute('data-event-type')==='tool').length; let bar=blocks.querySelector(':scope > .transparent-event-controls'); if(!rows.length){ if(bar) bar.remove(); return; } if(!bar){ bar=document.createElement('div'); bar.className='transparent-event-controls'; const label=document.createElement('span'); label.className='transparent-event-controls-label'; label.setAttribute('data-transparent-tool-count','1'); const expand=document.createElement('span'); expand.className='transparent-event-control'; expand.setAttribute('role','button'); expand.setAttribute('tabindex','0'); expand.setAttribute('data-transparent-expand-all','1'); expand.textContent=t('expand_all')||'Expand all'; expand.onclick=function(ev){ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),true);}; expand.onkeydown=function(ev){if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),true);}}; const collapse=document.createElement('span'); collapse.className='transparent-event-control'; collapse.setAttribute('role','button'); collapse.setAttribute('tabindex','0'); collapse.setAttribute('data-transparent-collapse-all','1'); collapse.textContent=t('collapse_all')||'Collapse all'; collapse.onclick=function(ev){ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),false);}; collapse.onkeydown=function(ev){if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),false);}}; bar.appendChild(label); bar.appendChild(expand); bar.appendChild(collapse); // Guard: firstChild may be null (empty blocks) or orphaned from a prior // DOM rebuild. Only insertBefore when it is still a child of blocks. if(blocks.firstChild&&blocks.firstChild.parentNode===blocks) blocks.insertBefore(bar, blocks.firstChild); else blocks.appendChild(bar); } const expand=bar.querySelector('[data-transparent-expand-all]'); if(expand){ expand.onclick=function(ev){ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),true);}; expand.onkeydown=function(ev){if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),true);}}; } const collapse=bar.querySelector('[data-transparent-collapse-all]'); if(collapse){ collapse.onclick=function(ev){ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),false);}; collapse.onkeydown=function(ev){if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),false);}}; } const label=bar.querySelector('.transparent-event-controls-label'); if(label){ label.textContent=_transparentEventCountLabel(toolCount); label.setAttribute('data-transparent-tool-count',String(toolCount)); } bar.setAttribute('data-tool-count',String(toolCount)); // Wire the Hermes chat name tag toggle for the live turn. _wireTransparentTurnToggle(turn); // Apply recency fade so the newest activity stands out while streaming. The // fade helper is internally gated to the live turn, so this no-ops on settled // turns — without this call the fade feature stayed dormant (only ever cleared // from the settled render loop). (Trifecta r2 follow-up.) _applyTransparentRowFading(turn); } function _rehydrateTransparentStreamDom(root){ if(!root||!isTransparentStream()) return; // Handle BOTH a container root and a root that IS itself an assistant turn // (the live-turn restore path passes the #liveAssistantTurn element directly, // which querySelectorAll('.assistant-turn') would not match). (Trifecta C1 r2.) const turns=[]; if(root.matches&&root.matches('.assistant-turn')) turns.push(root); root.querySelectorAll('.assistant-turn').forEach(turn=>turns.push(turn)); turns.forEach(turn=>{ _wireTransparentTurnToggle(turn); _syncTransparentEventControls(turn); }); root.querySelectorAll('.transparent-event-row').forEach(row=>{ const card=row.querySelector('.tool-card,.thinking-card'); const header=row.querySelector('.tool-card-header,.thinking-card-header'); if(header){ _wireTransparentHeaderToggle(header); _attachCopyButton(header); } if(card) _setTransparentCardOpen(card,card.classList.contains('open')); }); } function _decorateTransparentEventRow(row, opts){ if(!row) return row; opts=opts||{}; const type=String(opts.type||row.getAttribute('data-event-type')||'event'); row.classList.add('transparent-event-row'); row.setAttribute('data-transparent-event-row','1'); row.setAttribute('data-transparent-stream','1'); row.setAttribute('data-event-type',type); if(opts.name) row.setAttribute('data-event-name',String(opts.name)); if(opts.segmentSeq) row.setAttribute('data-live-segment-seq',String(opts.segmentSeq)); if(opts.burstId) row.setAttribute('data-activity-burst-id',String(opts.burstId)); if(type==='tool'){ const tc=opts.toolCall||row._tcData||{}; const name=String(opts.name||tc.name||'tool'); row.setAttribute('data-tool-name',name); const status=String(opts.status||_transparentToolStatus(tc)); row.setAttribute('data-event-status',status); const header=row.querySelector('.tool-card-header'); const card=row.querySelector('.tool-card'); if(card) card.classList.add('transparent-event-card'); if(header){ const nameEl=header.querySelector('.tool-card-name'); // The status badge (now legible per V2) already carries "Running", so don't // also prefix the name with "Running:" — that's the same redundancy class // V6 removed from the detail body. (Trifecta r2 #4.) if(nameEl) nameEl.textContent=_toolShortName(name); // #4658: restore the collapsed-row inline summary. buildToolCard emits a // `.tool-card-preview` span but blanks its text for the common case, and // the bare _toolShortName above drops the target the worklog view carries // in its action-label name. Populate the preview from a quiet, // target-based summary so each collapsed row says what it did without // expanding. Idempotent across re-decoration (status updates re-run this). const previewEl=header.querySelector('.tool-card-preview'); if(previewEl){ const summary=_transparentToolSummary(tc); if(summary){ previewEl.textContent=summary; previewEl.removeAttribute('hidden'); }else{ previewEl.textContent=''; } } let statusEl=header.querySelector('.transparent-event-status'); if(!statusEl){ statusEl=document.createElement('span'); statusEl.className='transparent-event-status'; const toggle=header.querySelector('.tool-card-toggle'); // Guard against stale toggle (see thinking-preview fix above). if(toggle&&toggle.parentNode===header) header.insertBefore(statusEl,toggle); else header.appendChild(statusEl); } if(status==='Completed'){ statusEl.textContent=''; statusEl.removeAttribute('data-status'); }else{ statusEl.textContent=status; statusEl.setAttribute('data-status',status.toLowerCase()); } row.setAttribute('data-event-status',status); // Update the 3D progress bar to reflect the new status. const progress=card.querySelector('.transparent-event-progress'); if(progress){ if(status==='Completed'||status==='Failed'||status==='Interrupted'){ progress.removeAttribute('data-progress-running'); progress.setAttribute('data-progress-percent','100%'); progress.style.setProperty('--transparent-progress-percent','100%'); }else if(status==='Running'){ progress.setAttribute('data-progress-running','1'); progress.setAttribute('data-progress-percent','60%'); progress.style.setProperty('--transparent-progress-percent','60%'); } } let detail=row.querySelector('.tool-card-detail'); if(!detail&&card){ card.insertAdjacentHTML('beforeend',_transparentToolDetailHtml(tc,status)); detail=row.querySelector('.tool-card-detail'); if(!header.querySelector('.tool-card-toggle')){ const toggle=document.createElement('span'); toggle.className='tool-card-toggle'; toggle.innerHTML=li('chevron-right',12); header.appendChild(toggle); } } if(detail&&!detail.querySelector('.transparent-detail-modes')){ const modes=document.createElement('div'); modes.className='transparent-detail-modes'; modes.setAttribute('role','tablist'); modes.innerHTML=`FullOutput`; // Guard: firstChild may be orphaned from a prior DOM rebuild. const firstChild=detail.firstChild; if(firstChild&&firstChild.parentNode===detail) detail.insertBefore(modes, firstChild); else detail.appendChild(modes); detail.setAttribute('data-transparent-detail-mode','full'); } _wireTransparentHeaderToggle(header); _attachCopyButton(header); } // Attach the 3D progress bar BEFORE the early return so tool rows // also get the bottom bar (the function used to return before // reaching the bar attachment, which left tool rows bar-less). _attachProgressBar(row, opts); return row; } if(type==='thinking'){ row.classList.add('transparent-thinking-event'); row.setAttribute('data-event-name','thinking'); const card=row.querySelector('.thinking-card'); const header=row.querySelector('.thinking-card-header'); if(card) card.classList.add('transparent-event-card'); if(header){ const btnRow=header.querySelector('.thinking-card-btn-row'); const copy=header.querySelector('.thinking-copy-btn,.transparent-event-copy'); const toggle=header.querySelector('.thinking-card-toggle'); if(copy&©.parentNode!==header) header.appendChild(copy); if(toggle&&toggle.parentNode!==header) header.appendChild(toggle); if(btnRow&&btnRow.parentNode===header&&!btnRow.children.length) btnRow.remove(); header.style.flexDirection='row'; const label=header.querySelector('.thinking-card-label'); if(label) label.textContent='Thinking'; let preview=header.querySelector('.transparent-event-preview,.transparent-event-thinking-preview'); const previewText=_transparentEventPreview(opts.preview||opts.text||row.textContent||''); if(previewText){ if(!preview){ preview=document.createElement('span'); preview.className='transparent-event-preview transparent-event-thinking-preview'; if(label&&label.parentNode===header&&label.nextSibling) header.insertBefore(preview,label.nextSibling); else if(label&&label.parentNode===header) header.appendChild(preview); else header.appendChild(preview); } preview.classList.add('transparent-event-thinking-preview'); preview.textContent=previewText; }else if(preview){ preview.remove(); } _wireTransparentHeaderToggle(header); _attachCopyButton(header); } _attachProgressBar(row, opts); } return row; } // ── 3D progress bar (loading path / follow-up indicator) ─────────── // Each transparent event card has a thin 3D bar at its bottom edge. // While the underlying tool is running (status === 'Running') the bar // shows a shimmer animation; once the tool completes the bar fills to // 100% and stops. The bar doubles as a visual step-break between rows // in the stack, so the eye reads the stream as discrete steps. function _attachProgressBar(row, opts){ if(!row) return; opts=opts||{}; const card=row.querySelector('.tool-card,.thinking-card'); if(!card) return; let bar=card.querySelector('.transparent-event-progress'); if(!bar){ bar=document.createElement('div'); bar.className='transparent-event-progress'; // Append to the card so it sits flush at the bottom edge. card.appendChild(bar); } // Set the running state from opts or current status. const status=String(opts.status||(row.getAttribute('data-event-status')||'')); const isRunning=(status==='Running'||status==='running'); const isCompleted=(status==='Completed'||status==='completed'||status==='Failed'||status==='failed'||status==='Interrupted'||status==='interrupted'); if(isRunning) bar.setAttribute('data-progress-running','1'); else bar.removeAttribute('data-progress-running'); if(isCompleted){ bar.setAttribute('data-progress-percent','100%'); bar.style.setProperty('--transparent-progress-percent','100%'); }else if(isRunning){ bar.setAttribute('data-progress-percent','60%'); bar.style.setProperty('--transparent-progress-percent','60%'); }else{ bar.removeAttribute('data-progress-percent'); bar.style.removeProperty('--transparent-progress-percent'); } } function _setTransparentRowsExpanded(root, expanded){ const scope=root||document; scope.querySelectorAll('.transparent-event-row .tool-card,.transparent-event-row .thinking-card').forEach(card=>{ _setTransparentCardOpen(card,!!expanded); }); } // ── Transparent turn-level collapse (Hermes chat name tag) ─────────────── // In transparent_stream mode the assistant role label is the turn's "name // tag". Clicking it collapses the entire event stack underneath so the // transcript shows only the final answer (Output only). A chevron on the // role telegraph the affordance; the blocks body animates with the same // max-height transition used by individual event cards. Persisted via // data-attribute only — the turn's render path reads it on rebuild. const _transparentTurnCollapsedStates={}; // key: `${sid}:${turnMsgIdx}` → boolean function _wireTransparentTurnToggle(turn){ if(!turn) return; if(!isTransparentStream()) return; const role=turn.querySelector('.msg-role.assistant'); if(!role) return; turn.setAttribute('data-transparent-turn-toggle-bound','1'); // Add chevron if missing. if(!role.querySelector('.transparent-turn-chevron')){ const chev=document.createElement('span'); chev.className='transparent-turn-chevron'; chev.innerHTML=li('chevron-down',10); role.appendChild(chev); } role.setAttribute('role','button'); role.setAttribute('tabindex','0'); role.setAttribute('aria-expanded',turn.getAttribute('data-transparent-turn-collapsed')==='1'?'false':'true'); const toggle=function(ev){ if(ev&&ev.target&&ev.target.closest&&ev.target.closest('.msg-tps-inline')) return; const collapsed=turn.getAttribute('data-transparent-turn-collapsed')==='1'; turn.setAttribute('data-transparent-turn-collapsed',collapsed?'0':'1'); role.setAttribute('aria-expanded',collapsed?'true':'false'); // Persist state across DOM rebuilds. if(S.session){ const seg=turn.querySelector('.assistant-segment'); if(seg){ const mi=seg.getAttribute('data-msg-idx'); if(mi!=null) _transparentTurnCollapsedStates[`${S.session.session_id}:${mi}`]=!collapsed; } } }; role.onclick=toggle; role.onkeydown=function(ev){ if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();toggle(ev);} }; } // ── Transparent old-event fading (medium → low) ─────────────────────────── // In long streams the earliest rows fade to a lower opacity so the user's // eye lands on the most recent activity. The fade is per-turn: the newest // event stays at full opacity, each earlier event drops one step. Floors // at 0.32 so labels stay readable. function _applyTransparentRowFading(turn){ if(!turn||!isTransparentStream()) return; // Recency-fading only makes sense on the LIVE turn (draw the eye to the most // recent activity). On settled/historical turns it permanently dims the trace // below readable contrast (floor .32) — the opposite of a transparent record. // So clear any fade on non-live turns and only fade the live turn. // (Trifecta finding V8.) const blocks=_assistantTurnBlocks(turn); if(!blocks) return; const rows=Array.from(blocks.querySelectorAll(':scope > .transparent-event-row')); const isLive=turn.id==='liveAssistantTurn'||turn.getAttribute('data-live-assistant-turn')==='1'; if(!isLive){ rows.forEach(row=>row.removeAttribute('data-transparent-fade')); return; } const total=rows.length; for(let i=0;i${esc(durationText)}`); if(ttftText) parts.push(`TTFT ${esc(ttftText)}`); if(tokensText) parts.push(`${esc(tokensText)}`); if(statusText) parts.push(`${esc(statusText)}`); if(!parts.length) return ''; return ``; } function _renderTransparentTurnFooter(turn, opts){ if(!turn||!isTransparentStream()) return; const blocks=_assistantTurnBlocks(turn); if(!blocks) return; const hasRows=blocks.querySelector(':scope > .transparent-event-row'); if(!hasRows){ // No events → no footer (the answer itself carries the duration). const existing=turn.querySelector('.transparent-turn-footer'); if(existing) existing.remove(); return; } const durationText=opts&&opts.durationText||''; const ttftText=opts&&opts.ttftText||''; const tokensText=opts&&opts.tokensText||''; const statusText=opts&&opts.statusText||(t('done')||'Done'); const html=_transparentTurnFooterHtml(durationText, ttftText, tokensText, statusText); let footer=turn.querySelector('.transparent-turn-footer'); if(!html){ if(footer) footer.remove(); return; } if(!footer){ footer=document.createElement('div'); footer.className='transparent-turn-footer'; const blocks=turn.querySelector('.assistant-turn-blocks'); // Guard: nextSibling may be null (blocks is last child) or orphaned from // a prior DOM rebuild. Only insertBefore when it is still a child of turn. if(blocks&&blocks.nextSibling&&blocks.nextSibling.parentNode===turn){ turn.insertBefore(footer, blocks.nextSibling); }else{ turn.appendChild(footer); } } footer.innerHTML=html.replace(/^