Files
hermes-webui/static/messages.js
T
nesquena-hermes 0378ed4e34 fix(routes): preserve live cards on cross-profile 409 collisions
Gate follow-up for #7710. Three front-end handlers treated EVERY 409 as a
domain-specific stale/expired signal, so the new session_profile_mismatch
response would have:

- messages.js: hidden a LIVE clarify card and mislabelled it expired
- ui.js: retired a LIVE compression-recovery card with a false note
- commands.js: rendered a false 'Compression failed' error state

Each now routes through _sessionProfileMismatchFromError() first and leaves
the card standing. Also captures the status in the import_cli test mock so a
wrong status can no longer pass (the mock swallowed it into *_).
2026-09-22 10:21:22 +00:00

9435 lines
441 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const _AGENT_COMMAND_ALIASES = {
'reload_mcp': 'reload-mcp',
'reload_skills': 'reload-skills',
'codex_runtime': 'codex-runtime',
'credits': 'credits'
};
const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set([
'reload-mcp','reload-skills','codex-runtime','credits',
'reload_mcp','reload_skills','codex_runtime','credits'
]);
function _markSessionViewed(sid, messageCount) {
if(typeof _setSessionViewedCount!=='function' || !sid) return;
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
_setSessionViewedCount(sid, next);
}
function _apiUrl(path) {
return new URL(path, document.baseURI || location.href).href;
}
// Module-scope dedupe ring buffer for bg_task_complete events. Shared between
// the in-turn STREAMS path (per-turn EventSource inside the chat-stream wirer)
// and the persistent session-scoped path (/api/session/stream), so the
// frontend never double-fires a toast or ack for the same (session_id,
// event_id) regardless of which channel delivered it first. (Option X)
//
// Keyed by `${session_id}|${event_id}` → expiry timestamp (ms since epoch).
// Bounded by a 60-second TTL plus a 256-entry soft cap with insertion-order
// eviction on overflow. Events without `event_id` are ignored by the caller
// (the server contract guarantees `event_id` on every completion emit).
const _BG_TASK_COMPLETE_TTL_MS = 60000;
const _BG_TASK_COMPLETE_CAP = 256;
const _bgTaskCompleteSeenIds = new Map();
function _bgTaskCompleteRingBufferAdd(sid, evt_id) {
// Missing key → treat as "seen/skip" (return true). The sole caller already
// guards with `if (!evt_id) return;` before invoking this, so this branch is
// defensive: returning true (skip) rather than false (proceed) means a
// future call site that forgets that guard drops the un-keyable event
// instead of processing a completion with no dedupe key.
if (!sid || !evt_id) return true;
const key = sid + '|' + evt_id;
const now = Date.now();
// Lazy purge: walk insertion-order; drop any entry whose expiry has passed.
// Map iteration is insertion-order so this also surfaces the oldest entries
// first when we need to evict for the soft cap below.
for (const [k, exp] of _bgTaskCompleteSeenIds) {
if (exp <= now) {
_bgTaskCompleteSeenIds.delete(k);
}
}
if (_bgTaskCompleteSeenIds.has(key)) return true; // duplicate
_bgTaskCompleteSeenIds.set(key, now + _BG_TASK_COMPLETE_TTL_MS);
// Soft cap: insertion-order eviction.
while (_bgTaskCompleteSeenIds.size > _BG_TASK_COMPLETE_CAP) {
const firstKey = _bgTaskCompleteSeenIds.keys().next().value;
if (firstKey === undefined) break;
_bgTaskCompleteSeenIds.delete(firstKey);
}
return false;
}
function _isDocumentVisibleAndFocused() {
if(typeof document!=='undefined' && document.visibilityState && document.visibilityState!=='visible') return false;
if(typeof document!=='undefined' && typeof document.hasFocus==='function' && !document.hasFocus()) return false;
return true;
}
let _desktopBackgroundedForNotifications=false;
// Desktop shells can background a visible document; keep that signal notification-only.
if(typeof window!=='undefined'){
window.__hermesSetBackgrounded=(value)=>{
_desktopBackgroundedForNotifications=!!value;
if(_desktopBackgroundedForNotifications){
for(const k in _STREAM_NOTIFICATION_BACKGROUND){
const e=_STREAM_NOTIFICATION_BACKGROUND[k];
if(e) e.wasBackgrounded=true;
}
}
};
}
function _isBackgroundedForBrowserNotification(){
return !!(typeof document!=='undefined'&&document.hidden)||_desktopBackgroundedForNotifications;
}
function _isSessionCurrentPane(sid) {
if(!sid || !S.session || S.session.session_id!==sid) return false;
// During session switching, S.session still points at the previous row until
// the next metadata request resolves. Do not let a just-finished old stream
// update the chat pane while the user is moving to another session.
if(typeof _loadingSessionId!=='undefined' && _loadingSessionId && _loadingSessionId!==sid) return false;
return true;
}
function _isSessionActivelyViewed(sid) {
if(!_isSessionCurrentPane(sid)) return false;
if(!_isDocumentVisibleAndFocused()) return false;
return true;
}
function _markActiveSessionViewedOnReturn() {
if(!_isDocumentVisibleAndFocused() || !S.session || !S.session.session_id) return;
_markSessionViewed(S.session.session_id, S.session.message_count || (S.messages&&S.messages.length) || 0);
if(typeof _clearSessionCompletionUnread==='function') _clearSessionCompletionUnread(S.session.session_id);
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
}
function _chatPayloadModel(){
return S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'';
}
function _chatPayloadModelProvider(model){
if(typeof _modelProviderForSend==='function') return _modelProviderForSend(model);
if(S.session&&S.session.model_provider) return S.session.model_provider||null;
return null;
}
function _chatPayloadModelState(){
// Source-compat invariant: the starting precedence is still
// model:S.session.model||$('modelSelect').value and
// model_provider:S.session.model_provider||null. The helper only fills a
// missing provider when it belongs to the same outgoing model.
const model=_chatPayloadModel();
return {model,model_provider:_chatPayloadModelProvider(model)};
}
function _deferStreamErrorIfOffline(){
if(typeof isOfflineBannerVisible==='function' && isOfflineBannerVisible()){
setComposerStatus(t('offline_stream_waiting'));
return true;
}
if(typeof showOfflineBanner==='function' && navigator.onLine===false){
showOfflineBanner('browser');
setComposerStatus(t('offline_stream_waiting'));
return true;
}
return false;
}
document.addEventListener('visibilitychange', _markActiveSessionViewedOnReturn);
window.addEventListener('focus', _markActiveSessionViewedOnReturn);
// Delegated click handler for the interim-progress-note collapse toggle (#2403).
// Delegation (not a per-element listener) is required because the live turn's
// DOM is snapshotted/restored via outerHTML/innerHTML on session switch
// (snapshotLiveTurnHtmlForSession / restoreLiveTurnHtmlForSession in ui.js),
// which strips element listeners. A document-level handler survives the
// restore so a restored toggle stays interactive and collapsed notes never
// become permanently unreachable. State lives in the DOM (presence of
// .interim-collapsed + data-threshold on the toggle), so the handler is
// stateless and works on freshly-created and restored toggles alike.
function _interimCollapseDelegatedClick(e){
const toggle=e.target&&e.target.closest?e.target.closest('.interim-collapse-toggle'):null;
if(!toggle) return;
const blocks=toggle.parentElement;
if(!blocks) return;
const threshold=parseInt(toggle.dataset.threshold,10)||3;
const hidden=blocks.querySelectorAll('.interim-collapsed');
if(hidden.length){
hidden.forEach(el=>el.classList.remove('interim-collapsed'));
toggle.dataset.expanded='1';
toggle.textContent='Collapse';
} else {
const all=Array.from(blocks.querySelectorAll('[data-interim="1"]'));
const rehide=all.slice(0,all.length-threshold);
rehide.forEach(el=>el.classList.add('interim-collapsed'));
toggle.dataset.expanded='';
toggle.textContent='Show '+rehide.length+' earlier update'+(rehide.length===1?'':'s');
}
}
document.addEventListener('click', _interimCollapseDelegatedClick);
// TTS: pause speech synthesis when user focuses the composer (#499)
const _msgEl=document.getElementById('msg');
if(_msgEl) _msgEl.addEventListener('focus', ()=>{ if('speechSynthesis' in window && speechSynthesis.speaking) speechSynthesis.pause(); });
if(_msgEl) _msgEl.addEventListener('blur', ()=>{ if('speechSynthesis' in window && speechSynthesis.paused) speechSynthesis.resume(); });
let _selectedTextReplyBtn=null;
let _selectedTextRefineBtn=null;
let _selectedTextReplyGroup=null;
let _selectedTextReplyText='';
let _pendingSelections=[]; // [{id, name, text}] — named context blocks
let _selectionIdCounter=0;
// #4380: expose a pending-selection predicate so the composer's primary-action
// content check (_composerHasContent in ui.js) treats selection-only replies as
// sendable content even though they no longer live in the textarea.
if(typeof window!=='undefined'){
window._hasPendingSelections=function(){return _pendingSelections.length>0;};
}
let _selectedTextReplyRaf=0;
const _persistentStateToastSeen=new Set();
const _thinkPairs=[
{open:'<think>',close:'</think>'},
{open:'<|channel>thought\n',close:'<channel|>'},
{open:'<|turn|>thinking\n',close:'<turn|>'}
];
function _thinkingFenceMarkerAt(text, index){
// A fenced code block opener may be indented up to 3 spaces in Markdown
// (4+ spaces is an indented code block, handled separately). Only treat the
// marker as a fence when it sits at a line start after optional 1-3 spaces.
if(index>0&&text[index-1]!=='\n'){
let back=index-1, spaces=0;
while(back>=0&&text[back]===' '&&spaces<3){back--;spaces++;}
if(!(back<0||text[back]==='\n')) return '';
}
if(text.startsWith('```',index)) return '```';
if(text.startsWith('~~~',index)) return '~~~';
return '';
}
function _nextThinkingOpener(text, start){
// Index of the earliest complete thinking opener at/after `start`, or -1.
// Cheap indexOf per opener — lets the scanner bulk-skip plain trailing content
// instead of walking it char-by-char (#3633 Codex per-token perf catch).
let best=-1;
for(const p of _thinkPairs){
const i=text.indexOf(p.open,start);
if(i!==-1&&(best===-1||i<best)) best=i;
}
return best;
}
function _textTailIsPartialOpener(text){
// True when the END of text is a non-empty proper prefix of some opener
// (e.g. "<thi" for "<think>"). Decides whether a streaming tail might be a
// forming block worth code-aware handling.
for(const p of _thinkPairs){
const m=Math.min(p.open.length-1,text.length);
for(let n=m;n>0;n--){ if(p.open.startsWith(text.slice(text.length-n))) return true; }
}
return false;
}
function _lineIsIndentedCode(text, lineStart){
// True when the line beginning at lineStart is a markdown indented code block
// line (>=4 leading spaces or a leading tab, and not blank). lineStart must be
// the first char of the line. Only inspects the line's leading chars, not the
// whole document (the per-character variant was O(n^2) on long no-newline
// content — #3633 Codex perf catch).
if(lineStart>=text.length) return false;
if(text[lineStart]==='\t'||text.startsWith(' ',lineStart)){
let nl=text.indexOf('\n',lineStart);
if(nl===-1) nl=text.length;
return text.slice(lineStart,nl).trim()!=='';
}
return false;
}
function _mergeInlineThinkingReasoning(existingReasoning, extractedParts){
let out=String(existingReasoning||'').trim();
(Array.isArray(extractedParts)?extractedParts:[]).forEach(function(part){
const item=String(part||'').trim();
if(!item) return;
if(!out){out=item;return;}
if(out===item||out.split('\n\n').some(function(existing){return existing.trim()===item;})) return;
out += '\n\n' + item;
});
return out;
}
function _extractInlineThinkingFromContent(rawContent, existingReasoning, options){
// Code-aware extraction (must mirror api/streaming.py
// _extract_inline_thinking_from_content): thinking tags inside a triple-fence,
// an inline single-backtick code span, or an indented code block are LEFT
// VISIBLE. options.streaming gates partial/unclosed handling — only during a
// live stream does an unmatched open tag mean "still thinking"; on the
// reload/render path an unclosed tag stays visible content (#3633 Codex catch).
const streaming=!!(options&&options.streaming);
const text=String(rawContent||'');
if(!text){
const reasoning=String(existingReasoning||'').trim();
return {reasoning,content:text,thinkingText:reasoning,displayText:text,inThinking:false};
}
// Fast path (#3633 Codex perf catch — _parseStreamState / syncInflightAssistantMessage
// call this on the FULL accumulator on every streamed token, so the common no-tag
// case must not do the O(length) char walk per call). If no complete opener is
// present AND — when streaming — the tail is not a prefix of an opener, there is
// nothing to extract: return the text unchanged (two cheap substring scans).
if(!_thinkPairs.some(p=>text.indexOf(p.open)!==-1)){
let tailIsPartialOpener=false;
if(streaming){
for(const p of _thinkPairs){
const maxPrefix=Math.min(p.open.length-1,text.length);
for(let n=maxPrefix;n>0;n--){
if(p.open.startsWith(text.slice(text.length-n))){tailIsPartialOpener=true;break;}
}
if(tailIsPartialOpener) break;
}
}
if(!tailIsPartialOpener){
const reasoning=String(existingReasoning||'').trim();
return {reasoning,content:text,thinkingText:reasoning,displayText:text,inThinking:false};
}
}
const visible=[];
const extracted=[];
let cursor=0;
let index=0;
let fence='';
let inBacktick=false;
let inThinking=false;
// Incremental O(1)-per-iteration line state + seen-nonspace flag (the previous
// per-character line scan + slice(0,index).trim() were O(n^2) on long
// no-newline content — #3633 Codex perf catch).
let lineIsIndentedCode=_lineIsIndentedCode(text,0);
let seenNonspace=false;
// Only lstrip the final content when a LEADING thinking block/prefix was
// removed — a reply that legitimately starts with indented code / whitespace
// and has no leading thinking wrapper keeps its leading whitespace (#3633
// Codex catch).
let leadingRemoved=false;
// Index of the next complete opener at/after `index` — lets the scanner bulk-skip
// plain trailing content instead of walking it char-by-char every streamed token
// (#3633 Codex per-token perf catch).
let nextOpener=_nextThinkingOpener(text,0);
while(index<text.length){
if(nextOpener===-1||index>nextOpener) nextOpener=_nextThinkingOpener(text,index);
if(nextOpener===-1){
// No further COMPLETE opener ahead — remaining tail is plain and is
// appended in one slice, EXCEPT during streaming when the tail is a prefix
// of an opener ("...<thi"): it may be a forming block and must be
// suppressed, but ONLY if outside code context (a partial opener inside
// inline-backtick / fenced / indented code stays visible — master parity).
// Code state needs the char walk, so fall through in that case (bounded —
// a partial tail is a transient single token) instead of bulk-skipping.
if(streaming&&_textTailIsPartialOpener(text)){
// fall through to the code-aware char walk for the tail
} else {
break;
}
}
const ch=text[index];
if(index>0&&text[index-1]==='\n') lineIsIndentedCode=_lineIsIndentedCode(text,index);
const marker=_thinkingFenceMarkerAt(text,index);
if(marker) fence=(fence===marker)?'':(fence||marker);
if(!fence&&!marker&&ch==='`') inBacktick=!inBacktick;
const inCode=!!fence||inBacktick||lineIsIndentedCode;
if(!inCode){
let pair=null;
for(const candidate of _thinkPairs){
if(text.startsWith(candidate.open,index)){pair=candidate;break;}
}
if(pair){
const closeIndex=text.indexOf(pair.close,index+pair.open.length);
if(closeIndex===-1){
// Unclosed open tag. A LEADING unclosed block (nothing visible before
// it) is a genuine thinking trace cut off mid-thought → reasoning
// (master #3455 leading-only intent + live "still thinking"). An
// unclosed tag AFTER visible content on the reload/render path is
// almost always a literal typed tag — leave it (and following prose)
// visible so nothing is silently truncated (#3633 Codex catch).
const leading=!seenNonspace;
if(!streaming&&!leading) break;
if(leading) leadingRemoved=true;
visible.push(text.slice(cursor,index));
const partial=text.slice(index+pair.open.length);
if(partial) extracted.push(partial);
inThinking=true;
cursor=text.length;
index=text.length;
break;
}
visible.push(text.slice(cursor,index));
extracted.push(text.slice(index+pair.open.length,closeIndex));
if(!seenNonspace) leadingRemoved=true;
seenNonspace=true;
index=closeIndex+pair.close.length;
cursor=index;
continue;
}
if(streaming){
let matchedPartial=false;
for(const candidate of _thinkPairs){
const rest=text.slice(index);
if(rest.length<candidate.open.length&&candidate.open.startsWith(rest)){
if(!seenNonspace) leadingRemoved=true;
visible.push(text.slice(cursor,index));
inThinking=true;
cursor=text.length;
index=text.length;
matchedPartial=true;
break;
}
}
if(matchedPartial||index>=text.length) break;
}
}
if(ch.trim()!=='') seenNonspace=true;
index++;
}
if(cursor<text.length) visible.push(text.slice(cursor));
const content=leadingRemoved?visible.join('').replace(/^\s+/,''):visible.join('');
const reasoning=_mergeInlineThinkingReasoning(existingReasoning,extracted);
return {reasoning,content,thinkingText:reasoning,displayText:content,inThinking};
}
if(typeof window!=='undefined'){
window._extractInlineThinkingFromContentForRender=function(rawContent, existingReasoning){
return _extractInlineThinkingFromContent(rawContent, existingReasoning, {streaming:false});
};
}
function enhanceMarkdownTables(root){
if(!root||!root.querySelectorAll) return;
const scope=root;
const tables=scope.querySelectorAll('.msg-body table:not([data-markdown-table-enhanced])');
const sortLabel=typeof t==='function'?t('markdown_table_sort_column'):'Sort column';
const filterLabel=typeof t==='function'?t('markdown_table_filter'):'Filter table';
tables.forEach((table)=>{
if(table.closest('.csv-table-wrap')) return;
const headRows=table.tHead?Array.from(table.tHead.rows):[];
const body=table.tBodies&&table.tBodies.length?table.tBodies[0]:table;
const bodyRows=Array.from(body.rows||[]).filter((row)=>row.parentElement===body);
const headerRow=headRows[0]||table.querySelector('tr');
if(!headerRow||!bodyRows.length) return;
table.setAttribute('data-markdown-table-enhanced','1');
bodyRows.forEach((row,idx)=>{ row.dataset.markdownTableOriginalIndex=String(idx); });
if(bodyRows.length>=4&&table.parentElement){
const filter=document.createElement('input');
filter.type='search';
filter.className='markdown-table-filter';
filter.placeholder=filterLabel;
filter.setAttribute('aria-label',filterLabel);
filter.autocomplete='off';
filter.spellcheck=false;
filter.addEventListener('input',()=>{
const query=_markdownTableText(filter.value).toLowerCase();
bodyRows.forEach((row)=>{
row.hidden=!!query&&!_markdownTableText(row.textContent).toLowerCase().includes(query);
});
});
table.parentElement.insertBefore(filter,table);
}
Array.from(headerRow.cells||[]).forEach((cell,colIdx)=>{
const button=document.createElement('button');
button.type='button';
button.className='markdown-table-sort';
const columnName=_markdownTableText(cell.textContent)||String(colIdx+1);
const columnSortLabel=`${sortLabel}: ${columnName}`;
button.setAttribute('aria-label',columnSortLabel);
button.title=columnSortLabel;
cell.setAttribute('aria-sort','none');
const label=document.createElement('span');
label.className='markdown-table-sort-label';
while(cell.firstChild) label.appendChild(cell.firstChild);
const indicator=document.createElement('span');
indicator.className='markdown-table-sort-indicator';
indicator.setAttribute('aria-hidden','true');
button.appendChild(label);
button.appendChild(indicator);
button.addEventListener('click',()=>{
const nextDir=table.dataset.markdownTableSortCol===String(colIdx)&&table.dataset.markdownTableSortDir==='asc'?'desc':'asc';
table.dataset.markdownTableSortCol=String(colIdx);
table.dataset.markdownTableSortDir=nextDir;
Array.from(headerRow.cells||[]).forEach((other)=>{
other.setAttribute('aria-sort','none');
});
cell.setAttribute('aria-sort',nextDir==='asc'?'ascending':'descending');
const rows=Array.from(body.rows||[]).filter((row)=>row.parentElement===body);
rows.sort((a,b)=>{
const av=_markdownTableCellText(a.cells[colIdx]);
const bv=_markdownTableCellText(b.cells[colIdx]);
const cmp=av.localeCompare(bv,undefined,{numeric:true,sensitivity:'base'});
if(cmp!==0) return nextDir==='asc'?cmp:-cmp;
const ai=Number(a.dataset.markdownTableOriginalIndex||0);
const bi=Number(b.dataset.markdownTableOriginalIndex||0);
return ai-bi;
});
rows.forEach((row)=>body.appendChild(row));
});
cell.appendChild(button);
});
});
}
function _sanitizeMarkdownTableCellText(cell){
if(!cell) return '';
const sortButton=cell.querySelector?cell.querySelector('.markdown-table-sort'):null;
if(sortButton){
const sortLabel=sortButton.querySelector?sortButton.querySelector('.markdown-table-sort-label'):null;
if(sortLabel) return _markdownTableCellText(sortLabel);
return _markdownTableCellText(sortButton);
}
return _markdownTableCellText(cell);
}
function _markdownTableCopyHtmlEscape(value){
return String(value||'')
.replace(/&/g,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;');
}
function _markdownTableCopyPayloadForTable(table){
if(!table||!table.rows) return null;
const rows=Array.from(table.rows||[]);
if(!rows.length) return null;
let headerRowCount=0;
while(headerRowCount<rows.length){
const cells=Array.from(rows[headerRowCount].cells||[]);
if(!cells.length||!cells.every((cell)=>cell&&cell.tagName==='TH')) break;
headerRowCount++;
}
const renderRows=(rowSet)=>rowSet.map((row)=>{
const cellTag=(cell)=>String(cell&&cell.tagName?cell.tagName.toLowerCase():'td');
const cells=Array.from(row.cells||[])
.filter((cell)=>cell&&cell.nodeType===1)
.map((cell)=>{
const tag=cellTag(cell);
const text=_sanitizeMarkdownTableCellText(cell);
return `<${tag}>${_markdownTableCopyHtmlEscape(text)}</${tag}>`;
})
.join('');
return `<tr>${cells}</tr>`;
}).join('');
const headerRows=headerRowCount?renderRows(rows.slice(0, headerRowCount)):'';
const bodyRows=renderRows(rows.slice(headerRowCount));
const tableSections=[
headerRows?`<thead>${headerRows}</thead>`:'',
bodyRows?`<tbody>${bodyRows}</tbody>`:'',
].join('');
const plainRows=rows.map((row)=>{
return Array.from(row.cells||[])
.map(_sanitizeMarkdownTableCellText)
.join('\t');
}).join('\n');
return {html:`<table>${tableSections}</table>`, plain:plainRows};
}
function _findEnhancedMarkdownTable(node){
let current=node&&node.nodeType===3?node.parentElement:node;
while(current){
if(current.matches&&current.matches('table[data-markdown-table-enhanced]')) return current;
current=current.parentElement||current.parentNode;
}
return null;
}
function _findMarkdownTableCell(node){
let current=node&&node.nodeType===3?node.parentElement:node;
while(current){
if(current.matches&&current.matches('th,td')) return current;
current=current.parentElement||current.parentNode;
}
return null;
}
function _markdownTableNodeChildren(node){
if(!node) return [];
if(node.childNodes&&typeof node.childNodes.length==='number') return Array.from(node.childNodes);
if(node.children&&typeof node.children.length==='number') return Array.from(node.children);
return [];
}
function _markdownTableNodeBoundaryLength(node){
if(!node) return 0;
if(node.nodeType===3) return String(node.textContent||'').length;
return _markdownTableNodeChildren(node).length;
}
function _markdownTableBoundaryWithinCell(container, offset, cell, edge){
if(!container||!cell||typeof offset!=='number') return false;
const atStart=edge==='start';
let current=container;
let currentOffset=offset;
while(current){
const boundaryLength=_markdownTableNodeBoundaryLength(current);
if(atStart){
if(currentOffset!==0) return false;
}else if(currentOffset!==boundaryLength){
return false;
}
if(current===cell) return true;
const parent=current.parentElement||current.parentNode;
if(!parent) return false;
const siblings=_markdownTableNodeChildren(parent);
const index=siblings.indexOf(current);
if(index===-1) return false;
currentOffset=atStart?index:index+1;
current=parent;
}
return false;
}
function _markdownTableEdgeCell(table, edge){
const rows=Array.from(table&&table.rows||[]);
if(!rows.length) return null;
const row=edge==='start'?rows[0]:rows[rows.length-1];
const cells=Array.from(row&&row.cells||[]);
if(!cells.length) return null;
return edge==='start'?cells[0]:cells[cells.length-1];
}
function _isFullEnhancedMarkdownTableSelection(range, table){
if(!range||!table) return false;
const firstCell=_markdownTableEdgeCell(table,'start');
const lastCell=_markdownTableEdgeCell(table,'end');
if(!firstCell||!lastCell) return false;
const startCell=_findMarkdownTableCell(range.startContainer);
const endCell=_findMarkdownTableCell(range.endContainer);
if(startCell!==firstCell||endCell!==lastCell) return false;
return _markdownTableBoundaryWithinCell(range.startContainer, range.startOffset, firstCell, 'start')
&& _markdownTableBoundaryWithinCell(range.endContainer, range.endOffset, lastCell, 'end');
}
function _findEnhancedMarkdownTableFromRange(range){
if(!range) return null;
const found=_findEnhancedMarkdownTable(range.startContainer)
|| _findEnhancedMarkdownTable(range.endContainer)
|| _findEnhancedMarkdownTable(range.commonAncestorContainer);
if(found) return found;
const container=range.commonAncestorContainer&&range.commonAncestorContainer.nodeType===3
? range.commonAncestorContainer.parentElement
: range.commonAncestorContainer;
if(!container||!container.querySelectorAll||typeof range.intersectsNode!=='function') return null;
for(const table of container.querySelectorAll('table[data-markdown-table-enhanced]')){
try{
if(range.intersectsNode(table)) return table;
}catch(_){}
}
return null;
}
function _handleMarkdownTableCopy(event){
if(!event) return;
if(!window.getSelection)return;
const selection=window.getSelection();
if(!selection||selection.isCollapsed||!selection.rangeCount) return;
const range=selection.getRangeAt(0);
if(!range) return;
const startCell=_findMarkdownTableCell(range.startContainer);
const endCell=_findMarkdownTableCell(range.endContainer);
if(startCell&&endCell&&startCell===endCell) return;
const table=_findEnhancedMarkdownTableFromRange(range);
if(!table||!table.matches||!table.matches('table[data-markdown-table-enhanced]')) return;
if(!_isFullEnhancedMarkdownTableSelection(range, table)) return;
const payload=_markdownTableCopyPayloadForTable(table);
if(!payload) return;
const clipboardData=event.clipboardData||event.originalEvent&&event.originalEvent.clipboardData;
if(!clipboardData||typeof clipboardData.setData!=='function') return;
if(typeof event.preventDefault==='function') event.preventDefault();
clipboardData.setData('text/html', payload.html);
clipboardData.setData('text/plain', payload.plain);
}
function _wireMarkdownTableCopyHandler(root){
if(!root||!root.addEventListener||root.__markdownTableCopyHandlerInstalled) return;
root.addEventListener('copy', _handleMarkdownTableCopy);
root.__markdownTableCopyHandlerInstalled=true;
}
function _markdownTableText(value){
return String(value||'').replace(/\s+/g,' ').trim();
}
function _markdownTableCellText(cell){
return _markdownTableText(cell?cell.textContent:'');
}
window.enhanceMarkdownTables=enhanceMarkdownTables;
(function _wireMarkdownTableEnhancer(){
if(typeof window==='undefined'||typeof window.renderMessages!=='function'||window.renderMessages._markdownTablesEnhanced) return;
const baseRenderMessages=window.renderMessages;
window.renderMessages=function(...args){
const result=baseRenderMessages.apply(this,args);
const inner=typeof $==='function'?$('msgInner'):document.getElementById('msgInner');
enhanceMarkdownTables(inner);
_wireMarkdownTableCopyHandler(inner);
return result;
};
window.renderMessages._markdownTablesEnhanced=true;
})();
function _persistentToastText(value){
if(value===null||value===undefined)return '';
if(typeof value==='string')return value;
try{return JSON.stringify(value);}catch(_){return String(value||'');}
}
function _persistentToastToolName(tool){
return String(tool&&tool.name||'').trim();
}
function _persistentToastArgs(tool){
const args=tool&&tool.args;
return args&&typeof args==='object'?args:{};
}
function _persistentToastPreview(tool){
return [
_persistentToastText(tool&&tool.preview),
_persistentToastText(tool&&tool.snippet),
].filter(Boolean).join('\n');
}
function _persistentToastHasWriteIntent(name, text){
const nameWords=String(name||'').replace(/_/g,' ');
const haystack=`${nameWords}\n${text}`.toLowerCase();
if(/\b(read|list|view|search|lookup|get|fetch|load|usage|toggle|delete|remove)\b/.test(nameWords))return false;
if(/\b(no|not|nothing)\s+(?:was\s+)?(?:saved|updated|created|written|stored|changed)\b/.test(haystack))return false;
if(/\b(?:unchanged|skipped|dry[- ]run|failed|error)\b/.test(haystack))return false;
return /\b(save|saved|write|wrote|written|update|updated|create|created|store|stored|persist|persisted|remember|remembered)\b/.test(haystack);
}
function _persistentToastSkillName(tool){
const args=_persistentToastArgs(tool);
const raw=args.name||args.skill_name||args.skill||args.title||'';
const direct=String(raw||'').trim();
if(direct)return direct;
const text=_persistentToastPreview(tool);
const match=text.match(/\bskill(?:\s+updated|\s+created|\s+saved)?\s*[:=]\s*["'`]?([A-Za-z0-9_.-]{2,80})/i);
return match?match[1]:'';
}
function _maybeNotifyPersistentStateSaved(tool){
if(!tool||tool.is_error||typeof showToast!=='function')return;
const name=_persistentToastToolName(tool);
if(!name)return;
const nameKey=name.toLowerCase().replace(/[^a-z0-9]+/g,'_');
const preview=_persistentToastPreview(tool);
const argsText=_persistentToastText(_persistentToastArgs(tool));
const text=`${preview}\n${argsText}`;
if(!_persistentToastHasWriteIntent(nameKey, text))return;
const nameWords=nameKey.replace(/_/g,' ');
const isSkill=/\bskills?\b/.test(nameWords);
const isMemory=/\b(memory|memories|remember|profile)\b/.test(nameWords);
if(!isSkill&&!isMemory)return;
const skillName=isSkill?_persistentToastSkillName(tool):'';
if(isSkill&&!skillName)return;
_showPersistentStateToast(isSkill?'skill':'memory', skillName, {
created: isSkill&&/\b(create|created|new)\b/.test(`${nameKey}\n${preview}`.toLowerCase()),
});
}
function _showPersistentStateToast(kind, name, options){
if(typeof showToast!=='function')return;
const normalizedKind=String(kind||'').toLowerCase();
if(normalizedKind!=='skill'&&normalizedKind!=='memory')return;
const itemName=String(name||'').trim();
const dedupeKey=[
S&&S.session&&S.session.session_id||'',
normalizedKind,
itemName||'memory',
].join(':');
if(_persistentStateToastSeen.has(dedupeKey))return;
_persistentStateToastSeen.add(dedupeKey);
if(_persistentStateToastSeen.size>200){
const first=_persistentStateToastSeen.values().next().value;
_persistentStateToastSeen.delete(first);
}
if(normalizedKind==='skill'){
const base=options&&options.created?t('skill_created'):t('skill_updated');
showToast(itemName?`${base}: ${itemName}`:base,4200,'success');
return;
}
showToast(t('memory_saved'),3600,'success');
}
function _selectedTextReplyT(key, fallback){
try{
const val=(typeof t==='function')?t(key):'';
return val&&val!==key?val:fallback;
}catch(_err){
return fallback;
}
}
function _selectedTextReplyRoot(){
if(typeof $==='function') return $('messages')||$('msgInner');
return document.getElementById('messages')||document.getElementById('msgInner');
}
function _selectedTextReplyNodeInChat(node, root){
if(!node||!root)return false;
const el=node.nodeType===Node.ELEMENT_NODE?node:node.parentElement;
return !!(el&&root.contains(el));
}
function _selectedTextReplySelection(){
if(!window.getSelection)return null;
const selection=window.getSelection();
if(!selection||selection.isCollapsed||!selection.rangeCount)return null;
const root=_selectedTextReplyRoot();
if(!root)return null;
const range=selection.getRangeAt(0);
if(!_selectedTextReplyNodeInChat(range.startContainer, root)||!_selectedTextReplyNodeInChat(range.endContainer, root))return null;
const text=selection.toString().replace(/\u00a0/g,' ').trim();
if(!text)return null;
const rect=range.getBoundingClientRect();
if(!rect||(!rect.width&&!rect.height))return null;
return {text, rect};
}
function _formatSelectedTextReplyQuote(text, includeMarker=true){
const normalized=String(text||'').replace(/\r\n?/g,'\n').replace(/\n{3,}/g,'\n\n').trim();
if(!normalized)return '';
const quote=normalized.split('\n').map(line=>`> ${line}`).join('\n');
return includeMarker?`<!-- hermes-selected-context -->\n${quote}`:quote;
}
function _appendComposerText(text){
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
if(!composer||!text)return;
const current=String(composer.value||'');
composer.value=current.trim()?`${current.replace(/\s+$/,'')}\n\n${text}`:String(text);
composer.focus();
try{composer.setSelectionRange(composer.value.length, composer.value.length);}catch(_e){}
composer.dispatchEvent(new Event('input',{bubbles:true}));
if(typeof autoResize==='function') autoResize();
}
function insertSavedPromptIntoComposer(text){
if(!text)return;
_appendComposerText(`${text}\n\n`);
}
function _seedSelectedTextRefineDraft(text){
const quote=_formatSelectedTextReplyQuote(text, false);
const instruction=_selectedTextReplyT('selected_text_refine_instruction','Refine instruction:');
if(!quote||!instruction)return;
_appendComposerText(`${quote}\n\n${instruction} `);
}
function _consumeSelectedTextReplySelection(){
const info=_selectedTextReplySelection();
if(!info){
_hideSelectedTextReplyButton();
return '';
}
const text=info.text;
_hideSelectedTextReplyButton();
const selection=window.getSelection&&window.getSelection();
if(selection&&selection.removeAllRanges)selection.removeAllRanges();
return text;
}
let _savedPromptsCache=null;
async function _loadSavedPrompts(){
try{
const data=await api('/api/prompts');
_savedPromptsCache=Array.isArray(data&&data.prompts)?data.prompts:[];
}catch(_e){_savedPromptsCache=[];}
return _savedPromptsCache;
}
async function toggleSavedPromptsPopup(){
const popup=(typeof $==='function'&&$('savedPromptsPopup'))||document.getElementById('savedPromptsPopup');
const btn=(typeof $==='function'&&$('btnSavedPrompts'))||document.getElementById('btnSavedPrompts');
if(!popup)return;
if(popup.style.display!=='none'){
popup.style.display='none';
if(btn)btn.setAttribute('aria-expanded','false');
return;
}
popup.innerHTML='<div class="saved-prompts-loading">Loading…</div>';
popup.style.display='flex';
if(btn)btn.setAttribute('aria-expanded','true');
const prompts=await _loadSavedPrompts();
popup.innerHTML='';
if(!prompts.length){
const empty=document.createElement('div');
empty.className='saved-prompts-empty';
empty.textContent=(typeof t==='function'&&t('saved_prompts_empty'))||'No saved prompts yet.';
popup.appendChild(empty);
}else{
for(const p of prompts){
const row=document.createElement('div');
row.className='saved-prompt-row';
row.setAttribute('role','menuitem');
const label=document.createElement('span');
label.className='saved-prompt-label';
label.textContent=p.label||p.text;
label.title=p.text;
row.onclick=()=>{
insertSavedPromptIntoComposer(p.text);
popup.style.display='none';
if(btn)btn.setAttribute('aria-expanded','false');
};
const del=document.createElement('button');
del.className='saved-prompt-delete';
del.type='button';
del.title=(typeof t==='function'&&t('saved_prompts_delete'))||'Delete';
del.innerHTML='<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
del.onclick=async(e)=>{
e.stopPropagation();
try{await api('/api/prompts',{method:'DELETE',body:JSON.stringify({id:p.id})});}catch(_e){}
_savedPromptsCache=null;
await toggleSavedPromptsPopup();
await toggleSavedPromptsPopup();
};
row.appendChild(label);
row.appendChild(del);
popup.appendChild(row);
}
}
const addRow=document.createElement('div');
addRow.className='saved-prompt-add-row';
const saveBtn=document.createElement('button');
saveBtn.type='button';
saveBtn.className='saved-prompt-save-btn';
saveBtn.textContent=(typeof t==='function'&&t('saved_prompts_save_current'))||'Save current input';
saveBtn.onclick=async()=>{
const msgEl=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
const text=(msgEl&&msgEl.value||'').trim();
if(!text){
if(typeof showToast==='function') showToast((typeof t==='function'&&t('saved_prompts_empty_input'))||'Type a prompt first',2000,'error');
return;
}
try{await api('/api/prompts',{method:'POST',body:JSON.stringify({text})});}catch(_e){
if(typeof showToast==='function') showToast(_e&&_e.message||'Failed to save prompt',2000,'error');
return;
}
_savedPromptsCache=null;
popup.style.display='none';
if(btn)btn.setAttribute('aria-expanded','false');
if(typeof showToast==='function') showToast((typeof t==='function'&&t('saved_prompts_saved'))||'Prompt saved',1600);
};
addRow.appendChild(saveBtn);
popup.appendChild(addRow);
}
document.addEventListener('click',(e)=>{
const popup=(typeof $==='function'&&$('savedPromptsPopup'))||document.getElementById('savedPromptsPopup');
const btn=(typeof $==='function'&&$('btnSavedPrompts'))||document.getElementById('btnSavedPrompts');
if(!popup||popup.style.display==='none')return;
if(!popup.contains(e.target)&&e.target!==btn&&!(btn&&btn.contains(e.target))){
popup.style.display='none';
if(btn)btn.setAttribute('aria-expanded','false');
}
},{capture:false});
function _addNamedContextBlock(text){
const id='ctx-'+(++_selectionIdCounter);
const name=(_selectedTextReplyT('context_block_name_default','Context'))+' '+_selectionIdCounter;
_pendingSelections.push({id, name, text});
_renderSelectionChips();
return id;
}
function _removeNamedContextBlock(id){
_pendingSelections=_pendingSelections.filter(s=>s.id!==id);
if(!_pendingSelections.length)_selectionIdCounter=0;
_renderSelectionChips();
}
function _clearPendingSelections(){
_selectionIdCounter=0;
if(!_pendingSelections.length)return false;
_pendingSelections=[];
_renderSelectionChips();
return true;
}
if(typeof window!=='undefined') window._clearPendingSelections=_clearPendingSelections;
function _selectedContextPreview(text){
const normalized=String(text||'').replace(/\r\n?/g,'\n').replace(/\n{3,}/g,'\n\n').trim();
if(!normalized)return '';
const max=360;
return normalized.length>max?normalized.slice(0,max).trimEnd()+'…':normalized;
}
function _renderSelectionChips(){
const wrap=document.getElementById('composerSelectionChips');
if(!wrap)return;
wrap.innerHTML='';
wrap.hidden=!_pendingSelections.length;
_pendingSelections.forEach(s=>{
const card=document.createElement('article');
card.className='selection-context-card';
card.dataset.selectionId=s.id;
card.setAttribute('aria-label', s.name);
const accent=document.createElement('div');
accent.className='selection-context-accent';
accent.setAttribute('aria-hidden','true');
const body=document.createElement('div');
body.className='selection-context-body';
const header=document.createElement('div');
header.className='selection-context-header';
const name=document.createElement('button');
name.type='button';
name.className='selection-context-name selection-chip-name';
name.textContent=s.name;
name.title=_selectedTextReplyT('context_block_rename_hint','Click or press Enter to rename');
name.setAttribute('aria-label', `${_selectedTextReplyT('context_block_rename_aria','Rename context block')}: ${s.name}`);
name.addEventListener('click',()=>_editSelectionChipName(s.id,card));
name.addEventListener('dblclick',()=>_editSelectionChipName(s.id,card));
name.addEventListener('keydown',e=>{
if(e.key==='Enter'||e.key===' '||e.key==='F2'){
e.preventDefault();
_editSelectionChipName(s.id,card);
}
});
const remove=document.createElement('button');
remove.type='button';
remove.className='selection-context-remove selection-chip-remove';
remove.setAttribute('aria-label', `${_selectedTextReplyT('context_block_remove','Remove context block')}: ${s.name}`);
remove.innerHTML='&#x2715;';
remove.addEventListener('click',()=>_removeNamedContextBlock(s.id));
const quote=document.createElement('blockquote');
quote.className='selection-context-quote';
quote.textContent=_selectedContextPreview(s.text);
quote.title=String(s.text||'');
header.appendChild(name);
header.appendChild(remove);
body.appendChild(header);
body.appendChild(quote);
card.appendChild(accent);
card.appendChild(body);
wrap.appendChild(card);
});
// #4380: pending selection cards are content the primary Send button must
// recognize (they were moved out of the textarea into _pendingSelections),
// so refresh the button's enabled/disabled state whenever the set changes —
// otherwise a selection-only reply can't be sent via click/tap/mobile.
if(typeof updateSendBtn==='function') updateSendBtn();
}
function _editSelectionChipName(id,chip){
const s=_pendingSelections.find(x=>x.id===id);
if(!s)return;
const nameEl=chip.querySelector('.selection-chip-name');
if(!nameEl)return;
if(chip.querySelector('.selection-chip-edit'))return;
const inp=document.createElement('input');
inp.type='text';inp.value=s.name;inp.className='selection-chip-edit';
inp.maxLength=120;
inp.setAttribute('aria-label', `${_selectedTextReplyT('context_block_rename_aria','Rename context block')}: ${s.name}`);
inp.title=_selectedTextReplyT('context_block_rename_hint','Click or press Enter to rename');
nameEl.replaceWith(inp);
inp.focus();inp.select();
let done=false;
const restoreFocus=()=>{
window.requestAnimationFrame(()=>{
const safeId=window.CSS&&CSS.escape?CSS.escape(id):String(id).replace(/"/g,'\\"');
const next=document.querySelector(`[data-selection-id="${safeId}"] .selection-chip-name`);
if(next&&typeof next.focus==='function')next.focus({preventScroll:true});
});
};
const commit=()=>{ if(done)return; done=true; s.name=(inp.value.trim()||s.name).slice(0,120); _renderSelectionChips(); restoreFocus(); };
const cancel=()=>{ if(done)return; done=true; _renderSelectionChips(); restoreFocus(); };
inp.addEventListener('blur',commit);
inp.addEventListener('keydown',e=>{ if(e.key==='Enter'){e.preventDefault();commit();} if(e.key==='Escape'){cancel();} });
}
function _composerTextWithPendingSelections(){
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
const current=String(composer&&composer.value||'');
if(!_pendingSelections.length)return current;
const blocks=_pendingSelections.map(s=>`**${s.name}:**\n${_formatSelectedTextReplyQuote(s.text)}`).join('\n\n');
return current.trim()?`${current.replace(/\s+$/,'')}\n\n${blocks}\n\n`:`${blocks}\n\n`;
}
function _clearComposerAfterQueuedSelectionSend(){
const sid=arguments.length?arguments[0]:(S.session&&S.session.session_id);
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
const draftText=composer?String(composer.value||''):'';
const draftFiles=Array.isArray(S.pendingFiles)?[...S.pendingFiles]:[];
if(composer)composer.value='';
if(sid&&typeof _clearComposerDraft==='function') _clearComposerDraft(sid,draftText,draftFiles);
_clearPendingSelections();
if(typeof autoResize==='function') autoResize();
}
function _flushSelectionBlocksToComposer(){
if(!_pendingSelections.length)return;
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
if(!composer)return;
composer.value=_composerTextWithPendingSelections();
_clearPendingSelections();
composer.focus();
try{ composer.setSelectionRange(composer.value.length, composer.value.length); }catch(_e){}
composer.dispatchEvent(new Event('input',{bubbles:true}));
if(typeof autoResize==='function') autoResize();
}
function _selectedTextReplyButton(){
if(_selectedTextReplyBtn)return _selectedTextReplyBtn;
const group=document.createElement('div');
group.id='selectedTextActionGroup';
group.className='selected-text-action-group';
const btn=document.createElement('button');
btn.type='button';
btn.id='selectedTextReplyBtn';
btn.className='selected-text-reply-btn';
btn.setAttribute('data-i18n', 'selected_text_reply');
btn.setAttribute('data-i18n-title', 'selected_text_reply_title');
btn.setAttribute('data-i18n-aria-label', 'selected_text_reply');
btn.textContent=_selectedTextReplyT('selected_text_reply', 'Reply with selection');
btn.title=_selectedTextReplyT('selected_text_reply_title', 'Append selected chat text as quoted context');
btn.setAttribute('aria-label', btn.textContent);
btn.addEventListener('mousedown', e=>e.preventDefault());
btn.addEventListener('click', e=>{
e.preventDefault();
const text=_consumeSelectedTextReplySelection();
if(text){
_addNamedContextBlock(text);
}
});
const refine=document.createElement('button');
refine.type='button';
refine.id='selectedTextRefineBtn';
refine.className='selected-text-refine-btn';
refine.setAttribute('data-i18n', 'selected_text_refine');
refine.setAttribute('data-i18n-title', 'selected_text_refine_title');
refine.setAttribute('data-i18n-aria-label', 'selected_text_refine');
refine.textContent=_selectedTextReplyT('selected_text_refine', 'Refine');
refine.title=_selectedTextReplyT('selected_text_refine_title', 'Start an editable refinement draft from the selection');
refine.setAttribute('aria-label', refine.textContent);
refine.addEventListener('mousedown', e=>e.preventDefault());
refine.addEventListener('click', e=>{
e.preventDefault();
const text=_consumeSelectedTextReplySelection();
if(text){
_seedSelectedTextRefineDraft(text);
}
});
group.appendChild(btn);
group.appendChild(refine);
document.body.appendChild(group);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
_selectedTextReplyBtn=btn;
_selectedTextRefineBtn=refine;
_selectedTextReplyGroup=group;
return btn;
}
function _hideSelectedTextReplyButton(){
_selectedTextReplyText='';
if(_selectedTextReplyGroup)_selectedTextReplyGroup.classList.remove('visible');
}
function _positionSelectedTextReplyButton(info){
const btn=_selectedTextReplyButton();
_selectedTextReplyText=info.text;
_selectedTextReplyGroup.classList.add('visible');
const gap=8;
const groupRect=_selectedTextReplyGroup.getBoundingClientRect();
const width=groupRect.width||250;
const height=groupRect.height||40;
const left=Math.min(Math.max(gap, info.rect.left+(info.rect.width/2)-(width/2)), Math.max(gap, window.innerWidth-width-gap));
const top=Math.max(gap, info.rect.top-height-gap);
_selectedTextReplyGroup.style.left=`${left}px`;
_selectedTextReplyGroup.style.top=`${top}px`;
}
function _updateSelectedTextReplyButton(){
if(_selectedTextReplyRaf)return;
_selectedTextReplyRaf=window.requestAnimationFrame(()=>{
_selectedTextReplyRaf=0;
const info=_selectedTextReplySelection();
if(!info){
_hideSelectedTextReplyButton();
return;
}
_positionSelectedTextReplyButton(info);
});
}
if(typeof document!=='undefined'){
document.addEventListener('selectionchange', _updateSelectedTextReplyButton);
document.addEventListener('mouseup', e=>{
if(e.target&&e.target.closest&&e.target.closest('.selected-text-action-group'))return;
_updateSelectedTextReplyButton();
});
document.addEventListener('keyup', e=>{
if(e.key&&/Arrow|Shift|Control|Meta|Alt/.test(e.key))_updateSelectedTextReplyButton();
});
window.addEventListener('resize', _hideSelectedTextReplyButton);
}
// Guard against concurrent send() calls. Without this, two rapid sends
// (e.g. queue drain + user click) can both pass the S.busy check because
// setBusy(true) is only called after the first await inside send().
let _sendInProgress = false;
let _sendInProgressSid = null; // session_id of the in-flight send
const _sessionTitleProvisionalBySid = new Map();
// Agent commands that are safe to execute directly in the WebUI even though
// their canonical command is registered on the backend (for example
// /reload-mcp). Keep this intentionally narrow and include underscore variants
// observed by users so typing either form still routes through executeAgentCommand.
function _clearStaleBusyStateBeforeSend({compressionRunning=false}={}){
if(!S||!S.busy||compressionRunning) return false;
const session=S.session||{};
const sid=session.session_id||'';
const hasRuntimeConfirmation=Boolean(
S.activeStreamId||
session.active_stream_id||
session.pending_user_message||
session.pending_started_at
);
if(hasRuntimeConfirmation) return false;
if(typeof INFLIGHT==='object'&&INFLIGHT&&sid&&INFLIGHT[sid]){
delete INFLIGHT[sid];
if(typeof clearInflightState==='function') clearInflightState(sid);
}
S.activeStreamId=null;
if(session) session.active_stream_id=null;
if(typeof setBusy==='function') setBusy(false);
else S.busy=false;
if(typeof setComposerStatus==='function') setComposerStatus('');
if(typeof setStatus==='function') setStatus('');
if(typeof updateSendBtn==='function') updateSendBtn();
if(sid&&typeof clearOptimisticSessionStreaming==='function') clearOptimisticSessionStreaming(sid);
return true;
}
function _runOptionalPreStartUiStep(label, fn){
try{
return typeof fn==='function'?fn():undefined;
}catch(e){
const message=e&&e.message?e.message:String(e||'unknown error');
try{console.warn('[webui] optional pre-start UI step failed', label, message);}catch(_){ }
return undefined;
}
}
function _runOptionalPostStartUiStep(label, fn){
try{
return typeof fn==='function'?fn():undefined;
}catch(e){
const message=e&&e.message?e.message:String(e||'unknown error');
try{console.warn('[webui] optional post-start UI step failed', label, message);}catch(_){ }
return undefined;
}
}
function _sessionTitleLooksDefaultOrProvisional(titleText, provisionalText){
const title=String(titleText||'').replace(/\s+/g,' ').trim();
if(!title||title==='Untitled'||title==='New Chat')return true;
const provisional=String(provisionalText||'').replace(/\s+/g,' ').trim().slice(0,64);
return !!provisional&&title===provisional;
}
function _firstUserMessageTitleCandidate(){
const first=(S.messages||[]).find(m=>m&&m.role==='user'&&m.content);
return first?String(first.content||'').trim().slice(0,64):'';
}
function applySessionTitleUpdate(sid, titleText, options={}){
const newTitle=String(titleText||'').trim();
if(!sid||!newTitle)return false;
const row=(typeof _allSessions!=='undefined'&&Array.isArray(_allSessions))
? _allSessions.find(s=>s&&s.session_id===sid)
: null;
const currentTitle=S.session&&S.session.session_id===sid
? S.session.title
: row&&row.title;
if(!options.force){
const expected=String(options.expectedCurrent||'').trim();
const remembered=_sessionTitleProvisionalBySid.get(sid)||'';
const provisionalCandidates=[options.provisionalText,remembered,_firstUserMessageTitleCandidate()];
const allowed=(expected&&String(currentTitle||'').trim()===expected)
|| String(currentTitle||'').trim()===newTitle
|| provisionalCandidates.some(p=>_sessionTitleLooksDefaultOrProvisional(currentTitle, p));
if(!allowed)return false;
}
if(S.session&&S.session.session_id===sid){
S.session.title=newTitle;
if(typeof syncTopbar==='function') syncTopbar();
}
if(row) row.title=newTitle;
if(options.rememberProvisional) _sessionTitleProvisionalBySid.set(sid,newTitle);
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
else if(typeof renderSessionList==='function') renderSessionList();
return true;
}
// #5472: when a provider/background error aborts a send, send() has already
// cleared the composer (`$('msg').value=''`), the persisted draft
// (`_clearComposerDraft`), and the staged files (uploadPendingFiles() sets
// `S.pendingFiles=[]`) before the turn was durably accepted server-side. On a
// start-time throw the turn is never persisted, so the user loses the entire
// typed message + attachments and must retype. Restore the ORIGINAL captured
// draft text + staged files so the user can re-send with one key press.
// Mirrors the draft-restore idiom already used by _trySteer (commands.js) and
// _stashClarifyDraft.
//
// `draftText` and `filesSnapshot` are immutable snapshots captured in send()
// BEFORE slash rewrites (/moa, bundles) mutate the payload and BEFORE
// uploadPendingFiles() drains S.pendingFiles — so we restore what the user
// actually typed, not the transformed send payload.
async function _recoverCompressedSend(error,sid,draftText,filesSnapshot,clearPromise){
let payload;
try{ payload=JSON.parse(error&&error.body||'{}'); }catch(_){ return false; }
const target=payload&&payload.continuation_session_id;
if(!error||error.status!==409||!payload||payload.code!=='session_rotated'||typeof target!=='string'||!target||target===sid) return false;
// A failed POST has not admitted a turn. Never resend automatically: the
// continuation may already be busy, and attachments must remain a draft.
if(!S.session||S.session.session_id!==sid) return false;
delete INFLIGHT[sid];
if(typeof clearInflightState==='function') clearInflightState(sid);
if(typeof clearOptimisticSessionStreaming==='function') clearOptimisticSessionStreaming(sid);
stopApprovalPolling();stopClarifyPolling();removeThinking();setBusy(false);
try{
await loadSession(target);
if(!S.session||S.session.session_id===sid) return false;
// loadSession can lose its navigation race to another tab selection. Never
// place the rejected message into that unrelated session's composer.
_restoreComposerDraftAfterFailedSend(draftText,filesSnapshot,target,clearPromise);
if(S.session.session_id!==target) return true;
setComposerStatus('Session resumed. Your message is preserved; send it when ready.');
showToast('Session resumed after compression. Your draft is preserved.',4000);
return true;
}catch(_){ return false; }
}
function _restoreComposerDraftAfterFailedSend(draftText, filesSnapshot, sid, clearPromise){
const restore=String(draftText||'');
const files=Array.isArray(filesSnapshot)?filesSnapshot.filter(Boolean):[];
if(!restore&&!files.length) return false;
// Only mutate the VISIBLE composer / staged tray when the failed send belongs
// to the session the user is currently looking at — otherwise a background
// send failure would pollute another session's composer. (Codex #5484 catch.)
const visibleSid=(S.session&&S.session.session_id)||null;
const belongsToVisible=!(sid&&visibleSid&&sid!==visibleSid);
let restoredVisible=false;
if(belongsToVisible){
const inp=$('msg');
// Do not clobber a new message the user began typing during the async window.
if(inp && !String(inp.value||'').trim()){
inp.value=restore;
if(typeof autoResize==='function') autoResize();
if(typeof updateSendBtn==='function') updateSendBtn();
// Re-stage the originally attached files so a one-key resend keeps them.
if(files.length){
S.pendingFiles=files;
if(typeof renderTray==='function') renderTray();
}
restoredVisible=true;
}
}
// Persist the failed session's draft so it survives a reload, ordered AFTER the
// send-time _clearComposerDraft POST (text:'') resolves — otherwise the two
// same-origin writes can be reordered under HTTP/2 multiplexing and leave the
// server draft empty. (Opus #5484 NIT.) Because the persist is deferred, it
// must be STALE-AWARE at fire time (Codex #5488 catch): if the failed session
// is still visible, re-read the LIVE composer so a post-restore edit is
// captured rather than clobbered by the original snapshot; if we restored the
// visible session but the user has since switched away, skip entirely (the
// session-switch save path already persisted this session's composer).
if(sid&&typeof _saveComposerDraftNow==='function'){
const _persist=()=>{
try{
const stillVisible=(S.session&&S.session.session_id)===sid;
if(stillVisible){
const inp=$('msg');
const liveText=inp?String(inp.value||''):restore;
_saveComposerDraftNow(sid, liveText, S.pendingFiles?[...S.pendingFiles]:[]);
} else if(!restoredVisible){
// Background failure (sid was never the visible session): no live
// composer to read, so persist the captured snapshot — it's the only copy.
_saveComposerDraftNow(sid, restore, files);
}
// else: restored the visible composer, then the user switched away — the
// session-switch save path already saved sid's composer; skip stale write.
}catch(_){ }
};
if(clearPromise&&typeof clearPromise.then==='function') clearPromise.then(_persist,_persist);
else _persist();
}
return restoredVisible;
}
async function send(){
// Static guards expect _defaultMessageMode to stay near send() while the actual
// read remains in the S.busy branch below.
// _defaultMessageMode
// Reject concurrent invocations early — before any await yields control.
// If a send is already in-flight (e.g. queue drain), re-queue the message
// instead of silently dropping it.
if (_sendInProgress) {
const _text=_composerTextWithPendingSelections().trim();
// Use the in-flight session's sid, not the currently viewed session,
// so the queued message goes to the chat that owns the active stream.
const _targetSid=_sendInProgressSid||(S.session&&S.session.session_id);
if(_text && _targetSid){
const _modelState=_chatPayloadModelState();
queueSessionMessage(_targetSid,{text:_text,files:[...S.pendingFiles],model:_modelState.model,model_provider:_modelState.model_provider,profile:S.activeProfile||'default'});
_clearComposerAfterQueuedSelectionSend();
if(_targetSid&&typeof _clearComposerDraft==='function'&&_targetSid!==(S.session&&S.session.session_id)) _clearComposerDraft(_targetSid,_text,S.pendingFiles?[...S.pendingFiles]:[]);
S.pendingFiles=[];renderTray();
updateQueueBadge(_targetSid);
showToast(`Queued: "${_text.slice(0,40)}${_text.length>40?'…':''}"`,2000);
}
return;
}
_sendInProgress = true;
try{
const options=arguments[0]||{};
const literalSlash=!!(options&&options.literalSlash);
let text=$('msg').value.trim();
if(!text&&!S.pendingFiles.length&&!_pendingSelections.length){_sendInProgress=false;_sendInProgressSid=null;return;}
// Don't send while an inline message edit is active
if(document.querySelector('.msg-edit-area')){_sendInProgress=false;_sendInProgressSid=null;return;}
_flushSelectionBlocksToComposer();
text=$('msg').value.trim();
if(!text&&!S.pendingFiles.length){_sendInProgress=false;_sendInProgressSid=null;return;}
if(typeof shouldInterceptCompressionRecoveryContinuation==='function'&&shouldInterceptCompressionRecoveryContinuation(text,S.pendingFiles)){
if(typeof showCompressionRecoveryContinuationHint==='function') showCompressionRecoveryContinuationHint();
_sendInProgress=false;_sendInProgressSid=null;
return;
}
// #5472: snapshot the ORIGINAL user-typed composer state now — before slash
// rewrites (/moa, bundles) mutate `text` and before uploadPendingFiles()
// clears S.pendingFiles. If /api/chat/start throws (turn never durably
// started), _restoreComposerDraftAfterFailedSend() puts this exact text +
// staged files back so the user can re-send without retyping. Captured as an
// immutable snapshot so later reassignments to `text` don't leak into it.
const _failedSendDraftText=text;
const _failedSendFilesSnapshot=Array.isArray(S.pendingFiles)?[...S.pendingFiles]:[];
// Dismiss handoff hint when user sends a message (resets seen_at).
if(S.session&&S.session.session_id&&typeof _dismissHandoffHint==='function'){
_dismissHandoffHint(S.session.session_id);
}
const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning();
_clearStaleBusyStateBeforeSend({compressionRunning});
// If busy or a manual compression is still running, handle based on default_message_mode
if(S.busy||compressionRunning){
if(text||S.pendingFiles.length){
if(!S.session){await newSession();await renderSessionList();}
// Busy-control slash commands must be intercepted HERE, before the
// defaultMessageMode routing block, so the user can always type /steer, /interrupt,
// /queue, /terminal, /goal, /yolo, or /stop while the agent is running and have
// them execute immediately.
// Without this intercept they fall through to the queue and execute after
// the current turn ends — by which point there is no active stream and
// cmdSteer / cmdInterrupt say "No active task to stop."
// /stop must cancel the active run immediately instead of being steered
// or queued as the literal text "/stop" (#6951).
if(text.startsWith('/')&&!literalSlash){
const _pc=typeof parseCommand==='function'&&parseCommand(text);
if(_pc&&['steer','interrupt','queue','terminal','goal','yolo','stop'].includes(_pc.name)){
const _bc=COMMANDS.find(c=>c.name===_pc.name);
if(_bc){
$('msg').value='';autoResize();
await _bc.fn(_pc.args);
return;
}
}
}
const defaultMessageMode=window._defaultMessageMode||'steer';
if(defaultMessageMode==='steer'&&S.activeStreamId&&typeof _trySteer==='function'){
// Real steer: clear the input first so the user gets immediate
// feedback, then ship the steer payload via /api/chat/steer.
// _trySteer captures the owner session/files before awaiting uploads,
// restores/persists the draft on failure, and clears the owner draft
// only after /api/chat/steer accepts.
$('msg').value='';autoResize();
// Do NOT clear pendingFiles yet — _trySteer uploads with clearPending=false,
// and a failed steer must keep staged files available for the user's next explicit action.
await _trySteer(text, /*explicitSteer=*/false);
// _trySteer clears staged files only after /api/chat/steer accepts, and
// only when the visible session still matches the captured owner sid.
} else if(defaultMessageMode==='interrupt'){
// Queue the message, then cancel so drain re-sends it.
const _modelState=_chatPayloadModelState();
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles],model:_modelState.model,model_provider:_modelState.model_provider,profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
_clearComposerAfterQueuedSelectionSend(S.session&&S.session.session_id);
S.pendingFiles=[];renderTray();
if(S.activeStreamId&&typeof cancelStream==='function'){
if(await cancelStream('busy-interrupt')) showToast(t('busy_interrupt_confirm'),2000);
else showToast(t('cancel_failed'),null,'error');
} else {
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'…':''}"`,2000);
}
} else {
// Default: queue mode (current behavior). Also the fallback for
// 'steer' mode when no stream is active or _trySteer is unavailable.
const _modelState=_chatPayloadModelState();
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles],model:_modelState.model,model_provider:_modelState.model_provider,profile:S.activeProfile||'default'});
_clearComposerAfterQueuedSelectionSend(S.session&&S.session.session_id);
S.pendingFiles=[];renderTray();
updateQueueBadge(S.session.session_id);
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'…':''}"`,2000);
}
}
return;
}
if(S.session&&(S.session.read_only||S.session.is_read_only)){
if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000);
return;
}
let _slashDisplayTextOverride=null;
let _pendingMoaConfig=null;
// Slash command intercept -- local commands handled without agent round-trip.
// We push the user message BEFORE running the handler for echo-worthy
// commands so chat order is correct: some handlers (e.g. cmdHelp) push
// their assistant response synchronously. If we pushed AFTER, S.messages
// would be [assistant, user] and the chat would show the response above
// the user's own input — reverse chronological order (#840 ordering bug).
if(text.startsWith('/')&&!S.pendingFiles.length&&!literalSlash){
const _parsedCmd=parseCommand(text);
const _cmd=_parsedCmd?COMMANDS.find(c=>c.name===_parsedCmd.name):null;
if(_cmd){
let _pushedUser=false;
if(!_cmd.noEcho){
if(!S.session){await newSession();await renderSessionList();}
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
_pushedUser=true;
renderMessages();
}
// Run the handler directly (we already looked it up). If it returns
// false it's opting out — e.g. /reasoning <level> falls through so the
// agent sees the raw text. Roll back the echo push in that case so
// the normal send path doesn't duplicate it.
if(_cmd.fn(_parsedCmd.args)===false){
if(_pushedUser){S.messages.pop();renderMessages();}
// Fall through to normal send path
} else {
$('msg').value='';autoResize();hideCmdDropdown();return;
}
}
if(_parsedCmd&&!_cmd){
if(_parsedCmd.name==='pet'){
if(!S.session){await newSession();await renderSessionList();}
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
let _petOutput=null;
try{
_petOutput=typeof handlePetSlashCommand==='function'
? await handlePetSlashCommand(text,{name:'pet'})
: {handled:false,message:'Desktop Companion is unavailable in WebUI.'};
}catch(e){
_petOutput={handled:false,message:`Desktop Companion command error: ${e&&e.message||e}`};
}
if(_petOutput&&_petOutput.message){
S.messages.push({role:'assistant',content:String(_petOutput.message),_ts:Date.now()/1000});
}
renderMessages();
$('msg').value='';autoResize();hideCmdDropdown();return;
}
if(_parsedCmd.name==='sessions' || _parsedCmd.name==='resume'){
// Open the native WebUI session browser rather than sending as chat text (#6224).
// Use the mobile-aware opener so phone-width layouts (where expandSidebar is a
// no-op) actually reveal the session drawer.
if(typeof _openProfileSwitchSessionBrowser==='function') _openProfileSwitchSessionBrowser();
else if(typeof expandSidebar==='function') expandSidebar();
if(typeof renderSessionList==='function') await renderSessionList();
$('msg').value='';autoResize();hideCmdDropdown();return;
}
const _agentCmd=typeof getAgentCommandMetadata==='function'
? await getAgentCommandMetadata(_parsedCmd.name)
: null;
if(_agentCmd&&_agentCmd.cli_only){
if(!S.session){await newSession();await renderSessionList();}
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
S.messages.push({role:'assistant',content:cliOnlyCommandResponse(_parsedCmd.name,_agentCmd),_ts:Date.now()/1000});
renderMessages();
$('msg').value='';autoResize();hideCmdDropdown();return;
}
const _agentCmdName=String(_agentCmd&&_agentCmd.name||_parsedCmd&&_parsedCmd.name||'').trim().toLowerCase();
if(_AGENT_COMMANDS_RUN_ON_WEBUI.has(_agentCmdName)){
if(!S.session){await newSession();await renderSessionList();}
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
let _agentOutput='(no output)';
try{
_agentOutput=typeof executeAgentCommand==='function'
? await executeAgentCommand(text,_agentCmd||{name:_agentCmdName})
: 'Agent command runtime unavailable in WebUI.';
}catch(e){
_agentOutput=`Agent command error: ${e&&e.message||e}`;
}
S.messages.push({role:'assistant',content:String(_agentOutput||'(no output)'),_ts:Date.now()/1000});
renderMessages();
$('msg').value='';autoResize();hideCmdDropdown();return;
}
if(_agentCmd&&_agentCmd.category==='Plugin'){
if(!S.session){await newSession();await renderSessionList();}
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
let _pluginOutput='(no output)';
try{
_pluginOutput=typeof executeAgentPluginCommand==='function'
? await executeAgentPluginCommand(text,_agentCmd)
: 'Plugin command runtime unavailable in WebUI.';
}catch(e){
_pluginOutput=`Plugin command error: ${e&&e.message||e}`;
}
S.messages.push({role:'assistant',content:String(_pluginOutput||'(no output)'),_ts:Date.now()/1000});
renderMessages();
$('msg').value='';autoResize();hideCmdDropdown();return;
}
if(_agentCmdName==='moa'){
const _moaArgs=(text.split(/\s+/).slice(1).join(' ')||'').trim();
if(!S.session){await newSession();await renderSessionList();}
if(!_moaArgs){
let _moaUsage='/moa <prompt>';
try{const _moaCfgU=await api('/api/commands/moa/resolve');_moaUsage=_moaCfgU.usage||_moaUsage;}catch(_eu){}
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
S.messages.push({role:'assistant',content:_moaUsage,_ts:Date.now()/1000});
renderMessages();$('msg').value='';autoResize();hideCmdDropdown();return;
}
try{
await api('/api/commands/moa/resolve');
_slashDisplayTextOverride=text;
text=_moaArgs;
_pendingMoaConfig=true;
}catch(_e){
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
S.messages.push({role:'assistant',content:'MoA unavailable: '+(_e&&_e.message||_e),_ts:Date.now()/1000});
renderMessages();$('msg').value='';autoResize();hideCmdDropdown();return;
}
}
const _bundleCmd=!_agentCmd&&typeof getBundleCommandMetadata==='function'
? await getBundleCommandMetadata(_parsedCmd.name)
: null;
if(_bundleCmd){
try{
const _bundleResolved=typeof resolveBundleCommand==='function'
? await resolveBundleCommand(text,_bundleCmd)
: null;
const _bundleMessage=String(_bundleResolved&&_bundleResolved.message||'').trim();
if(!_bundleMessage) throw new Error('Bundle command runtime returned no invocation text.');
_slashDisplayTextOverride=text;
text=_bundleMessage;
}catch(e){
if(!S.session){await newSession();await renderSessionList();}
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
S.messages.push({role:'assistant',content:`Bundle command error: ${e&&e.message||e}`,_ts:Date.now()/1000});
renderMessages();
$('msg').value='';autoResize();hideCmdDropdown();return;
}
}
}
}
if(!S.session){await newSession();await renderSessionList();}
const activeSid=S.session.session_id;
_sendInProgressSid=activeSid;
// Salvage of #4750 (@harryazj): capture the composer text and clear the
// textarea NOW — immediately after capture and BEFORE the uploadPendingFiles()
// / forced-skill-directive awaits below. send() re-reads the LIVE composer when
// it is re-entered while a send is in flight (the _sendInProgress guard at the
// top of this function reads _composerTextWithPendingSelections()). If we
// cleared only after the async work — as the pre-fix code did, down at the
// _clearComposerDraft site — a re-entrant/interrupt-mode send during the upload
// window would read the still-populated DOM and double-submit the same message.
// _submittedDraftTextForClear is the sole authority for the send-time draft
// signature from here down; no code path below re-reads $('msg').value on the
// happy path.
const _submittedDraftTextForClear=$('msg').value||'';
$('msg').value='';autoResize();
// #5912 gate CORE fix: snapshot the pending files that belong to THIS send
// BEFORE the await, and upload exactly that snapshot. Otherwise a re-entrant /
// interrupt-mode send during the upload window inherits the still-live
// S.pendingFiles and later re-uploads the first send's attachment. Detach the
// snapshot from S.pendingFiles now so files staged AFTER this point belong to
// the next send only.
const _submittedFiles=[...(S.pendingFiles||[])];
const _submittedDraftFilesForClear=[..._submittedFiles];
S.pendingFiles=[];
if(typeof renderTray==='function')renderTray();
// #5912 gate SILENT fix: clear the PERSISTED draft here — alongside the
// textarea clear, BEFORE any await — so a new draft typed during the upload
// window is not clobbered by a delayed text:'' post. Keep the promise so the
// #5472 failed-send restore can chain its re-persist after this clear resolves.
let _composerDraftClearPromise=null;
if (activeSid && typeof _clearComposerDraft === 'function') _composerDraftClearPromise=_clearComposerDraft(activeSid,_submittedDraftTextForClear,_submittedDraftFilesForClear);
setComposerStatus(_submittedFiles.length?'Uploading…':'');
let uploaded=[];
try{uploaded=await uploadPendingFiles({files:_submittedFiles, sessionId:activeSid, clearPending:false});}
catch(e){if(!text){setComposerStatus(`Upload error: ${e.message}`);return;}}
// Clear the uploading status now that upload is done — if we don't clear here
// it stays visible for the entire duration of the agent stream, since
// setComposerStatus('') is only called in setBusy(false), not setBusy(true).
setComposerStatus('');
const uploadedNames=uploaded.map(u=>u.name||u);
const uploadedPaths=uploaded.map(u=>u&&u.path?u.path:(u&&u.name?u.name:(u&&u.filename?u.filename:u)));
let msgText=text;
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploadedPaths.join(', ')}`;
else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploadedPaths.join(', ')}]`;
if(_forcedSkillDirectivePending){
const _pending=_forcedSkillDirectivePending;
if(!_pending.sessionId||_pending.sessionId===activeSid){
const _directivePayload = await _pending.promise;
if(_forcedSkillDirectivePending===_pending)_forcedSkillDirectivePending = null;
if(_directivePayload){
const _directive = typeof _directivePayload==='string'
? _directivePayload
: String(_directivePayload.directive||'').trim();
const _forcedSkillName = typeof _directivePayload==='string'
? ''
: String(_directivePayload.name||'').trim();
const _forcedSkillContent = typeof _directivePayload==='string'
? ''
: String(_directivePayload.content||'').trim();
const _forcedSkillBlock = _forcedSkillName&&_forcedSkillContent
? `[FORCED SKILL CONTEXT: ${_forcedSkillName}]\n${_forcedSkillContent}\n[/FORCED SKILL CONTEXT]`
: '';
msgText=`${_directive}${_forcedSkillBlock?`\n\n${_forcedSkillBlock}`:''}\n\n${msgText||''}`.trim();
}
}
}
if(!msgText){setComposerStatus('Nothing to send');return;}
// Composer textarea + persisted draft were already captured and cleared
// immediately after capture (above, salvage of #4750 + #5912 gate fix) to close
// the re-entrant double-send race AND avoid clobbering a draft typed during the
// upload window. _composerDraftClearPromise / _submittedDraftFilesForClear are
// set there; nothing to re-declare here.
const displayText=_slashDisplayTextOverride||text||(uploaded.length?`Uploaded: ${uploadedNames.join(', ')}`:'(file upload)');
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000,_pending:true};
S.toolCalls=[]; // clear tool calls from previous turn
clearLiveToolCards(); // clear any leftover live cards from last turn
let optimisticMessages;
try{
S.messages.push(userMsg);renderMessages();setBusy(true);
if(S.session&&!S.session.pending_started_at) S.session.pending_started_at=Date.now()/1000;
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
else appendThinking('',{pending:true});
// First optimistic pass: make the local user turn visible before /api/chat/start
// can save pending state on the server.
_runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.initial', ()=>{
if(typeof upsertActiveSessionForLocalTurn==='function'){
upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
}
});
optimisticMessages=[...S.messages];
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]});
}
_runOptionalPreStartUiStep('renderSessionListFromCache.initial', ()=>{
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
});
_runOptionalPreStartUiStep('startApprovalPolling.prestart', ()=>startApprovalPolling(activeSid));
_runOptionalPreStartUiStep('startClarifyPolling.prestart', ()=>startClarifyPolling(activeSid));
_runOptionalPreStartUiStep('fetchYoloState.prestart', ()=>_fetchYoloState(activeSid)); // sync YOLO pill with backend state
S.activeStreamId = null; // will be set after stream starts
_runOptionalPreStartUiStep('updateSendBtn.prestart', ()=>{
if(typeof updateSendBtn==='function') updateSendBtn();
});
// Set provisional title from user message immediately so session appears
// in the sidebar right away with a meaningful name. /api/chat/start persists
// the server-side provisional title and may refine this optimistic text.
if(S.session&&(S.session.title==='Untitled'||!S.session.title)){
const provisionalTitle=displayText.slice(0,64);
_runOptionalPreStartUiStep('applySessionTitleUpdate.provisional', ()=>{
applySessionTitleUpdate(activeSid, provisionalTitle, {force:true, rememberProvisional:true});
});
_runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.provisional', ()=>{
if(typeof upsertActiveSessionForLocalTurn==='function'){
// Second optimistic pass: carry the provisional title into the cached row
// without re-fetching /api/sessions before pending state exists server-side.
upsertActiveSessionForLocalTurn({title:provisionalTitle,messageCount:S.messages.length,timestampMs:Date.now()});
}
});
} else if(typeof upsertActiveSessionForLocalTurn==='function'){
_runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.titled', ()=>{
upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
});
} else {
_runOptionalPreStartUiStep('renderSessionListFromCache.prestart', ()=>{
renderSessionListFromCache(); // ensure it's visible even if already titled
});
}
}catch(preStartError){
// The user turn must reach /api/chat/start even if local optimistic UI
// bookkeeping (render cache, storage quota, sidebar reconciliation, etc.)
// throws. Otherwise the pane can show a user bubble + spinner while the
// backend never receives the turn.
const message=preStartError&&preStartError.message?preStartError.message:String(preStartError||'unknown error');
try{console.warn('[webui] pre-start optimistic UI failed; continuing to /api/chat/start', message);}catch(_){ }
if(!S.messages.includes(userMsg)) S.messages.push(userMsg);
optimisticMessages=[...S.messages];
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
try{setBusy(true);}catch(_){S.busy=true;}
if(S.session&&!S.session.pending_started_at) S.session.pending_started_at=Date.now()/1000;
S.activeStreamId=null;
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
}
// Start the agent via POST, get a stream_id back
let streamId;
let postStartData;
let modelStateForPostStart;
let explicitPickForPostStart;
try{
const _modelState=_chatPayloadModelState();
modelStateForPostStart=_modelState;
const _pendingPick=(typeof _readPendingSessionModel==='function')
? _readPendingSessionModel(activeSid)
: null;
const _pendingPickMatch=_pendingPick
&& _pendingPick.model===_modelState.model
&& String(_pendingPick.model_provider||'')===String(_modelState.model_provider||'');
// ── Persisted cross-provider pick (#3737 follow-up) ──
// The onchange marker is consumed after the first send, so subsequent sends
// lose explicit_model_pick and the server "repairs" the model back to the
// profile default. When the session has a non-default model from a different
// provider than the profile's active provider, treat every send as explicit
// so the server honors the user's choice across the entire conversation.
const _defaultModel=(typeof window!=='undefined' && window._defaultModel)||'';
const _activeProvider=(typeof window!=='undefined' && window._activeProvider)||null;
const _isCrossProviderPick = _modelState.model
&& _modelState.model_provider
&& _defaultModel
&& _activeProvider
&& _modelState.model !== _defaultModel
&& String(_modelState.model_provider||'') !== String(_activeProvider||'');
const _explicitPick = _pendingPickMatch || _isCrossProviderPick;
// Consume the pending explicit-pick marker for THIS send only. The marker is
// recorded on modelSelect.onchange and intentionally kept (not cleared on
// session-update) so it survives the normal pick→update→send flow; clear it here
// once read so a later send of an unchanged dropdown isn't treated as an explicit
// pick. (#3739/#3737, Codex catch)
if(_pendingPickMatch && typeof _clearPendingSessionModel==='function') _clearPendingSessionModel(activeSid);
explicitPickForPostStart=_explicitPick;
const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
session_id:activeSid,message:msgText,
// S.session.model remains authoritative; the helper only resolves a
// matching provider fallback for the same outgoing model.
model:_modelState.model,workspace:S.session.workspace,
model_provider:_modelState.model_provider,
profile:S.activeProfile||S.session.profile||'default',
explicit_model_pick:_explicitPick||undefined,
attachments:uploaded.length?uploaded:undefined,
moa_config:_pendingMoaConfig?true:undefined
})});
_pendingMoaConfig=null;
postStartData = startData;
}catch(e){
const errMsg=String((e&&e.message)||'');
// If /api/chat/start returns 404, the session was deleted server-side
// (its sidecar is gone) while GET kept returning a CLI stub (#2782). Strip
// the stale /session/<id> URL and clear localStorage so a reload does not
// re-inject the dead id via _sessionIdFromLocation(), then reset to the
// empty state instead of pushing a confusing error bubble into the chat.
if(e&&e.status===404){
try{ localStorage.removeItem('hermes-webui-session'); }catch(_){ }
try{
if(typeof _appRootPath==='function') history.replaceState(null,'',_appRootPath());
else history.replaceState(null,'',window.location.pathname.replace(/\/session\/[^/]+/,'')+window.location.search);
}catch(_){ }
delete INFLIGHT[activeSid];
if(typeof clearInflightState==='function') clearInflightState(activeSid);
stopApprovalPolling();
stopClarifyPolling();
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId || _clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
removeThinking();
S.session=null;S.messages=[];
setBusy(false);setComposerStatus('');
if(typeof clearOptimisticSessionStreaming==='function') clearOptimisticSessionStreaming(activeSid);
if(typeof renderMessages==='function') renderMessages();
if($('emptyState')) $('emptyState').style.display='';
if($('msgInner')) $('msgInner').innerHTML='';
if(typeof renderSessionList==='function') void renderSessionList();
return;
}
if(await _recoverCompressedSend(e,activeSid,_failedSendDraftText,_failedSendFilesSnapshot,_composerDraftClearPromise)) return;
const conflictActiveStream=/session already has an active stream/i.test(errMsg);
if(conflictActiveStream){
delete INFLIGHT[activeSid];
if(typeof clearInflightState==='function') clearInflightState(activeSid);
stopApprovalPolling();
stopClarifyPolling();
// Keep the user's attempted turn by queueing it for after the current run.
const _retryModelState=_chatPayloadModelState();
queueSessionMessage(activeSid,{text:msgText,files:[],model:_retryModelState.model,model_provider:_retryModelState.model_provider,profile:S.activeProfile||'default'});
updateQueueBadge(activeSid);
showToast('Current session is still running. Reconnected and queued your message.',2600);
try{
await loadSession(activeSid);
setComposerStatus('');
return;
}catch(_){
// Fall through to standard error handling if session reload fails.
}
}
delete INFLIGHT[activeSid];
stopApprovalPolling();
stopClarifyPolling();
// Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);removeThinking();
if(!_clarifySessionId || _clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
S.messages.push({role:'assistant',content:`**Error:** ${errMsg}`});
_queueDrainSid=activeSid;renderMessages();setBusy(false);setComposerStatus(`Error: ${errMsg}`);
// #5472: the send was rejected before the turn was durably started, so the
// composer text + attachments (cleared at send time) would otherwise be
// lost. Put back the ORIGINAL captured draft (not the mutated /moa/bundle
// payload) and re-stage files so the user can re-send without retyping.
_restoreComposerDraftAfterFailedSend(_failedSendDraftText, _failedSendFilesSnapshot, activeSid, _composerDraftClearPromise);
if(typeof clearOptimisticSessionStreaming==='function') clearOptimisticSessionStreaming(activeSid);
// Reconcile with server truth after immediately clearing the optimistic spinner.
if(typeof renderSessionList==='function') void renderSessionList();
return;
}
const startData = postStartData || {};
streamId = postStartData ? postStartData.stream_id : null;
S.activeStreamId = streamId;
// setBusy(true) already ran with activeStreamId=null; refresh now that we
// have a stream id so the primary button can switch to Stop (see
// getComposerPrimaryAction).
if(typeof updateSendBtn==='function') updateSendBtn();
_runOptionalPostStartUiStep('post-start ui/bookkeeping', ()=>{
const _modelState=modelStateForPostStart || _chatPayloadModelState();
const _explicitPick=explicitPickForPostStart;
if(startData&&startData.title) applySessionTitleUpdate(activeSid, startData.title, {provisionalText:displayText.slice(0,64), rememberProvisional:true});
if(startData&&startData.effective_model && S.session){
const _sentModel=_modelState&&_modelState.model;
if(_explicitPick && _sentModel && startData.effective_model!==_sentModel && typeof showToast==='function'){
showToast('Model '+_sentModel+' changed to '+startData.effective_model+' — profile provider mismatch', 5000);
}
S.session.model=startData.effective_model;
S.session.model_provider=startData.effective_model_provider||S.session.model_provider||null;
localStorage.setItem('hermes-webui-model', startData.effective_model);
if(typeof _writePersistedModelState==='function') _writePersistedModelState(startData.effective_model,S.session.model_provider||null);
if($('modelSelect')) _applyModelToDropdown(startData.effective_model, $('modelSelect'),S.session.model_provider||null);
if(typeof syncTopbar==='function') syncTopbar();
}else if(startData&&startData.effective_model_provider && S.session){
S.session.model_provider=startData.effective_model_provider;
if(typeof _writePersistedModelState==='function') _writePersistedModelState(S.session.model||'',S.session.model_provider||null);
if($('modelSelect')&&typeof _applyModelToDropdown==='function') _applyModelToDropdown(S.session.model||'', $('modelSelect'), S.session.model_provider||null);
if(typeof syncModelChip==='function') syncModelChip();
if(typeof syncTopbar==='function') syncTopbar();
}
if(S.session&&typeof startData.pending_started_at==='number'){
S.session.pending_started_at=startData.pending_started_at;
}
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
else if(typeof appendThinking==='function') appendThinking('',{pending:true});
// setBusy(true) already ran with activeStreamId=null; refresh now that we
// have a stream id so the primary button can switch to Stop (see
// getComposerPrimaryAction).
if(typeof updateSendBtn==='function') updateSendBtn();
if(S.session&&S.session.session_id===activeSid){
S.session.active_stream_id = streamId;
}
if(S.session&&S.session.session_id===activeSid&&typeof showLiveRunStatus==='function'){
const _startedAt=typeof startData?.pending_started_at==='number'
? startData.pending_started_at
: (S.session.pending_started_at||Date.now()/1000);
showLiveRunStatus(activeSid,{startedAt:_startedAt});
}
if(typeof upsertActiveSessionForLocalTurn==='function'){
// Third optimistic pass: stream_id is now known, so the row can reconcile
// against real active-stream metadata before the background refresh lands.
upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
}
if(!INFLIGHT[activeSid]){
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
}
const currentInflight=INFLIGHT[activeSid];
markInflight(activeSid, streamId);
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId,messages:currentInflight.messages||optimisticMessages,uploaded:uploadedNames,toolCalls:currentInflight.toolCalls||[]});
}
// Refresh session list so background streaming indicators appear immediately for the
// session that was just started and any others that may already be running.
if(typeof renderSessionList === 'function') {
void renderSessionList();
}
});
// Open SSE stream and render tokens live
attachLiveStream(activeSid, streamId, uploadedNames);
}finally{ _sendInProgress=false; _sendInProgressSid=null; }
}
async function startRegeneration(sessionId, regenerationRevision){
const sid=String(sessionId||'');
if(!sid||!regenerationRevision||!S.session||S.session.session_id!==sid)return;
const snapshot=Array.isArray(S.messages)?S.messages.slice():[];
let assistantIndex=-1;
let userIndex=-1;
for(let i=snapshot.length-1;i>=0;i--){
if(assistantIndex<0&&snapshot[i]?.role==='assistant'){assistantIndex=i;continue;}
if(assistantIndex>=0&&snapshot[i]?.role==='user'){userIndex=i;break;}
}
if(userIndex<0)return;
const retained=Object.assign({},snapshot[userIndex],{_pending:true});
S.messages=snapshot.slice(0,userIndex+1);
S.messages[userIndex]=retained;
renderMessages();setBusy(true);
if(typeof ensureLiveWorklogShell==='function')ensureLiveWorklogShell();
else if(typeof appendThinking==='function')appendThinking('',{pending:true});
try{
const response=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
session_id:sid,regenerate:true,regeneration_revision:regenerationRevision
})});
if(!S.session||S.session.session_id!==sid)return;
const streamId=response&&response.stream_id;
if(!streamId)throw new Error('Regeneration did not start a stream.');
S.activeStreamId=streamId;
S.session.active_stream_id=streamId;
S.session.regeneration_revision=null;
if(typeof response.pending_started_at==='number')S.session.pending_started_at=response.pending_started_at;
if(response.title&&typeof applySessionTitleUpdate==='function')applySessionTitleUpdate(sid,response.title);
if(!INFLIGHT[sid])INFLIGHT[sid]={messages:S.messages.slice(),uploaded:[],toolCalls:[]};
markInflight(sid,streamId);
if(typeof saveInflightState==='function')saveInflightState(sid,{streamId,messages:S.messages.slice(),uploaded:[],toolCalls:[]});
if(typeof showLiveRunStatus==='function')showLiveRunStatus(sid,{startedAt:S.session.pending_started_at||Date.now()/1000});
if(typeof updateSendBtn==='function')updateSendBtn();
if(typeof renderSessionList==='function')void renderSessionList();
attachLiveStream(sid,streamId,[]);
}catch(error){
if(S.session&&S.session.session_id===sid){
S.messages=snapshot;delete INFLIGHT[sid];
if(typeof clearInflightState==='function')clearInflightState(sid);
removeThinking();renderMessages();setBusy(false);setComposerStatus('');
}
throw error;
}
}
const LIVE_STREAMS={};
const _STREAM_NOTIFICATION_BACKGROUND={};
// #4416: track whether the tab was hidden at ANY point during a live stream, so
// the response-complete notification fires for a backgrounded tab even when
// Chromium throttles the background-tab SSE and delivers the `done` event LATE
// (after the user returns, when document.hidden already reads false). Each entry
// is STREAM-OWNED ({streamId, wasHidden}) so a stale entry left by a non-`done`
// terminal path (apperror/cancel/stream-error/reconnect-no-active) can never be
// mis-attributed to a later stream for the same session id — a reconnect only
// keeps the prior state when the streamId matches. One idempotent
// visibilitychange listener (never leaks) flips wasHidden on all active entries.
const _STREAM_WAS_HIDDEN={};
let _streamHiddenTrackerBound=false;
function _bindStreamHiddenTracker(){
if(_streamHiddenTrackerBound||typeof document==='undefined'||typeof document.addEventListener!=='function') return;
_streamHiddenTrackerBound=true;
document.addEventListener('visibilitychange',()=>{
if(document.hidden){ for(const k in _STREAM_WAS_HIDDEN){ const e=_STREAM_WAS_HIDDEN[k]; if(e) e.wasHidden=true; } }
});
}
function _clearStreamHidden(sid, streamId){
// Clear only when we own the current stream's entry (or unconditionally when
// streamId is omitted). Prevents a terminal path for an old stream from wiping
// a newer stream's tracker.
if(!sid) return;
const e=_STREAM_WAS_HIDDEN[sid];
if(!e) return;
if(streamId&&e.streamId&&e.streamId!==streamId) return;
delete _STREAM_WAS_HIDDEN[sid];
}
function _clearStreamNotificationBackground(sid, streamId){
if(!sid) return;
const e=_STREAM_NOTIFICATION_BACKGROUND[sid];
if(!e) return;
if(streamId&&e.streamId&&e.streamId!==streamId) return;
delete _STREAM_NOTIFICATION_BACKGROUND[sid];
}
function _shouldForceCompletionNotification(sid, streamId){
const hiddenEntry=_STREAM_WAS_HIDDEN[sid];
const backgroundEntry=_STREAM_NOTIFICATION_BACKGROUND[sid];
const wasHidden=!!(hiddenEntry&&hiddenEntry.wasHidden);
const wasBackgrounded=!!(backgroundEntry&&backgroundEntry.wasBackgrounded);
_clearStreamHidden(sid, streamId);
_clearStreamNotificationBackground(sid, streamId);
return wasHidden||wasBackgrounded;
}
function closeLiveStream(sessionId, streamId, source){
const live=LIVE_STREAMS[sessionId];
if(!live) return;
if(streamId&&live.streamId!==streamId) return;
if(source&&live.source!==source) return;
// Snapshot the current live-turn DOM BEFORE tearing the stream down. The
// per-event snapshot (snapshotLiveTurn) only fires on content/tool_complete
// SSE events, so switching away during a quiet window (mid tool-exec, silent
// thinking) would leave a stale-or-absent snapshot — on switch-back
// restoreLiveTurnHtmlForSession() then fails and loadSession()'s fallback
// rebuilds with an EMPTY appendThinking(), permanently losing the streamed
// thinking/tool content (only the elapsed clock survives). Capturing here
// guarantees switch-back restores the exact state shown at switch-away. (#3668)
if(typeof snapshotLiveTurnHtmlForSession==='function') snapshotLiveTurnHtmlForSession(sessionId);
// Stop the live footer timer/status for the pane that is being detached; the
// reattach path will rebuild it from INFLIGHT/server state if the user returns.
if(typeof _clearLiveRunStatusTimer==='function') _clearLiveRunStatusTimer(sessionId);
if(typeof hideLiveRunStatus==='function') hideLiveRunStatus(sessionId);
try{if(live.source&&live.source.readyState!==2)live.source.close();}catch(_){ }
delete LIVE_STREAMS[sessionId];
_resumeSessionStreamAfterLiveChat(sessionId);
// closeLiveStream() is called during session-switch teardown for any session
// the user is no longer viewing. The stream is still active on the server,
// so mark the in-memory INFLIGHT entry for reattach — otherwise
// loadSession() returning to this session skips the reattach branch
// (`INFLIGHT.reattach` was only set by the storage-load path) and the SSE
// is never reopened. The user then sees no streamed tokens until the LLM
// finishes and a metadata refresh swaps in the final reply.
// If the stream is terminating cleanly, _clearOwnerInflightState() has
// already deleted INFLIGHT[sessionId], so this is a safe no-op.
if(INFLIGHT[sessionId]){
INFLIGHT[sessionId].reattach=true;
// The browser-side INFLIGHT snapshot is only a compact tail cache. After a
// session switch it cannot be treated as the full live turn; rebuild from
// the durable run journal instead so earlier prose/tool rows are not lost.
INFLIGHT[sessionId].journalReplayFromStart=true;
if(typeof saveInflightState==='function'){
saveInflightState(sessionId,{
streamId:live.streamId||streamId||null,
messages:INFLIGHT[sessionId].messages||[],
uploaded:INFLIGHT[sessionId].uploaded||[],
toolCalls:INFLIGHT[sessionId].toolCalls||[],
lastAssistantText:INFLIGHT[sessionId].lastAssistantText||'',
lastReasoningText:INFLIGHT[sessionId].lastReasoningText||'',
lastRunJournalSeq:INFLIGHT[sessionId].lastRunJournalSeq||0,
lastRunJournalEventId:INFLIGHT[sessionId].lastRunJournalEventId||'',
journalReplayFromStart:true,
currentActivityBurstId:INFLIGHT[sessionId].currentActivityBurstId||0,
currentLiveSegmentSeq:INFLIGHT[sessionId].currentLiveSegmentSeq||0,
activityBurstAnchors:Array.isArray(INFLIGHT[sessionId].activityBurstAnchors)?INFLIGHT[sessionId].activityBurstAnchors:[],
});
}
}
}
function closeOtherLiveStreams(activeSid){
// Keep the live token SSE connection scoped to the conversation pane the user
// is actually viewing. Background sessions still show running/finished state
// through the session list and can reattach when selected, but they should not
// keep one EventSource each and exhaust the browser connection pool (#2313).
for(const sid of Object.keys(LIVE_STREAMS)){
if(sid!==activeSid) closeLiveStream(sid);
}
}
function _dispatchExtensionTurnLifecycle(type,sessionId,streamId,details={}){
const runtime=typeof window!=='undefined'&&window.HermesExtensionSettings;
const dispatch=runtime&&runtime._dispatchTurnLifecycle;
if(typeof dispatch!=='function') return false;
try{
return dispatch(type,{sessionId,streamId,...details});
}catch(error){
if(typeof console!=='undefined'&&typeof console.error==='function'){
try{console.error('[Hermes extensions] lifecycle dispatch failed:',error);}catch(_loggingError){ }
}
return false;
}
}
function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!activeSid||!streamId) return;
const reconnecting=!!options.reconnecting;
const _extensionTurnStartedAt=(S.session&&S.session.session_id===activeSid&&Number.isFinite(S.session.pending_started_at))
?S.session.pending_started_at
:Date.now()/1000;
// #4416: start (or, on reconnect for the SAME stream, keep) tracking whether
// the tab was hidden during this stream so the done-notification fires for a
// backgrounded tab. A reconnect with a different streamId re-seeds (the old
// entry belonged to a prior stream).
_bindStreamHiddenTracker();
{
const _prev=_STREAM_WAS_HIDDEN[activeSid];
const _keep=reconnecting&&_prev&&_prev.streamId===streamId;
if(!_keep){
_STREAM_WAS_HIDDEN[activeSid]={streamId,wasHidden:(typeof document!=='undefined'&&!!document.hidden)};
}
const _prevBackground=_STREAM_NOTIFICATION_BACKGROUND[activeSid];
const _keepBackground=reconnecting&&_prevBackground&&_prevBackground.streamId===streamId;
if(!_keepBackground){
_STREAM_NOTIFICATION_BACKGROUND[activeSid]={streamId,wasBackgrounded:_desktopBackgroundedForNotifications};
}
}
if(!INFLIGHT[activeSid]) INFLIGHT[activeSid]={messages:[...S.messages],uploaded:[...uploaded],toolCalls:[]};
else {
if(uploaded.length) INFLIGHT[activeSid].uploaded=[...uploaded];
if(!Array.isArray(INFLIGHT[activeSid].toolCalls)) INFLIGHT[activeSid].toolCalls=[];
}
const _priorInflightStreamId=String(INFLIGHT[activeSid].streamId||'');
if(_priorInflightStreamId&&_priorInflightStreamId!==streamId){
INFLIGHT[activeSid].lastRunJournalSeq=0;
INFLIGHT[activeSid].lastRunJournalEventId='';
}
INFLIGHT[activeSid].streamId=streamId;
if(!Array.isArray(INFLIGHT[activeSid].activityBurstAnchors)) INFLIGHT[activeSid].activityBurstAnchors=[];
if(INFLIGHT[activeSid].currentActivityBurstId===undefined) INFLIGHT[activeSid].currentActivityBurstId=0;
if(INFLIGHT[activeSid].currentLiveSegmentSeq===undefined) INFLIGHT[activeSid].currentLiveSegmentSeq=0;
let assistantText='';
let reasoningText='';
if(S.session&&S.session.session_id===activeSid&&S.activeStreamId===streamId&&typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
const existingLive=LIVE_STREAMS[activeSid];
if(
existingLive&&existingLive.streamId===streamId&&existingLive.source&&
// During explicit reconnects, only reuse a proven-open transport. A stale
// CONNECTING EventSource can survive in page state while the server has no
// subscriber, which leaves the live pane blank forever.
(typeof EventSource==='undefined'||
existingLive.source.readyState===EventSource.OPEN||
(!reconnecting&&existingLive.source.readyState===EventSource.CONNECTING))
){
// Phase D: restore bottom run status on reattach after the Worklog shell
// exists. There is no stale transport teardown in this branch.
if(reconnecting && S.activeStreamId && typeof showLiveRunStatus==='function'){
const _startedAt=(S.session&&S.session.pending_started_at)||Date.now()/1000;
showLiveRunStatus(activeSid,{startedAt:_startedAt});
}
return;
}
closeOtherLiveStreams(activeSid);
closeLiveStream(activeSid);
if(!reconnecting&&typeof resetTurnWorkspaceMutations==='function') resetTurnWorkspaceMutations();
if(!reconnecting&&typeof _resetStreamScrollFollow==='function') _resetStreamScrollFollow();
// Phase D: restore bottom run status after closeLiveStream(); that helper
// hides the status while tearing down stale EventSource ownership.
if(reconnecting && S.activeStreamId && typeof showLiveRunStatus==='function'){
const _startedAt=(S.session&&S.session.pending_started_at)||Date.now()/1000;
showLiveRunStatus(activeSid,{startedAt:_startedAt});
}
_suspendSessionStreamForLiveChat(activeSid);
// On reconnect, restore accumulated text from INFLIGHT so we don't lose
// progress made before the session switch. Without this the closure starts
// empty and tokens arriving on the new SSE connection append to nothing —
// the already-rendered content vanishes.
const _liveInflightAssistantMessages = reconnecting
? ((INFLIGHT[activeSid]&&Array.isArray(INFLIGHT[activeSid].messages))
? INFLIGHT[activeSid].messages.filter(m=>m&&m.role==='assistant'&&m._live)
: [])
: [];
const _liveInflightAssistant = _liveInflightAssistantMessages.length===1
? _liveInflightAssistantMessages[0]
: null;
const _fullInflightAssistant = (INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastAssistantText) || '';
const _joinedInflightSegments = _liveInflightAssistantMessages.length>1
? _liveInflightAssistantMessages.map(m=>m&&m.content?String(m.content).trim():'').filter(Boolean).join('\n\n')
: '';
const _lastLiveAssistant = reconnecting
? (_liveInflightAssistantMessages.length>1
? (_fullInflightAssistant || _joinedInflightSegments)
: (_liveInflightAssistant
? (_fullInflightAssistant || _liveInflightAssistant.content || '')
: _fullInflightAssistant))
: '';
const _lastLiveReasoning = reconnecting
? (_liveInflightAssistant&&_liveInflightAssistant.reasoning)
|| (INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastReasoningText)
|| ''
: '';
assistantText = _lastLiveAssistant ? _lastLiveAssistant : '';
reasoningText=_lastLiveReasoning ? _lastLiveReasoning : '';
let liveReasoningText = reasoningText;
let visibleInterimSnippets=[];
let _latestGoalStatus=null;
let _pendingGoalContinuation=null;
let assistantRow=null;
let assistantBody=null;
// On reconnect with recorded burst anchors, the rendered DOM has multiple
// live assistant segments — one per anchor plus a tail. New tokens belong to
// the TAIL segment only.
let segmentStart=(()=>{
if(!reconnecting) return 0;
const inflight=INFLIGHT[activeSid];
if(!inflight) return 0;
const anchors=Array.isArray(inflight.activityBurstAnchors)?inflight.activityBurstAnchors:[];
const textLen=String(assistantText||'').length;
let lastEnd=0;
for(const a of anchors){
const end=Number(a&&a.textEnd);
if(Number.isFinite(end)&&end>lastEnd&&end<=textLen) lastEnd=end;
}
return lastEnd;
})();
// If reconnect resumes exactly at the last recorded boundary, there is no
// projected tail segment yet. The next token must create a fresh segment
// after the last Activity group instead of rewriting the previous burst's
// text segment.
let _freshSegment=reconnecting&&segmentStart>0&&segmentStart>=String(assistantText||'').length;
// streaming-markdown state: incremental DOM-building parser per segment
let _smdParser=null; // current smd parser instance (null until first content)
let _smdWrittenLen=0; // how many chars of displayText have been fed to smd parser
let _smdWrittenText=''; // exact displayText snapshot used for prefix-alignment checks
let _streamingKatexTimer=null; // throttles live KaTeX scans while smd writes deltas
// On reconnect, the assistantBody already has partial smd-rendered content.
// We clear it on first new token and restart the parser from the reconnect point.
let _smdReconnect=reconnecting;
function _isActiveSession(){
return !!(S.session&&S.session.session_id===activeSid);
}
function _ownsActiveStreamOrBackground(){
return !_isActiveSession() || S.activeStreamId===streamId;
}
function _bailOutOfTerminalEventsFromStaleStream(source){
if(_ownsActiveStreamOrBackground()) return false;
// This stale stream no longer owns the session — schedule cleanup of ITS own
// anchor registry (identity-guarded, so it can't clobber the newer stream's
// registry for the same session) before closing. (Codex leak catch.)
_scheduleAnchorRegistryCleanup(120000);
_closeSource(source);
return true;
}
function _clearActivePaneInflightIfOwner(){
if(_isActiveSession()) clearInflight();
}
function _approvalBelongsToOwner(){
return _approvalSessionId===activeSid||(!_approvalSessionId&&_isActiveSession());
}
function _clarifyBelongsToOwner(){
return _clarifySessionId===activeSid||(!_clarifySessionId&&_isActiveSession());
}
function _clearApprovalForOwner(){
_clearApprovalPendingForSession(activeSid);
if(!_approvalBelongsToOwner()) return;
stopApprovalPolling();
hideApprovalCard(true);
}
function _clearClarifyForOwner(reason){
_clearClarifyPendingForSession(activeSid);
if(!_clarifyBelongsToOwner()) return;
stopClarifyPolling();
hideClarifyCard(true, reason||'terminal');
}
function _clearOwnerInflightState(){
if(_isActiveSession() && S.activeStreamId!==streamId) return;
delete INFLIGHT[activeSid];
clearInflightState(activeSid);
_clearActivePaneInflightIfOwner();
_resumeSessionStreamAfterLiveChat(activeSid);
}
function _isMarkerOnlyAssistantMessage(m){
if(!m||m.role!=='assistant') return false;
const text=String(typeof msgContent==='function'?msgContent(m):(m.content||''));
return typeof _isPreservedCompressionTaskListMarkerOnlyText==='function'
&& _isPreservedCompressionTaskListMarkerOnlyText(text);
}
function _streamRecoveryControlMessageText(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 _streamRecoveryControlMessage(m){
if(!m||m.role==='tool') return false;
if(m.recovery_control===true) return true;
// Backward-compat ONLY for pre-marker persisted sessions: match the two
// fully-anchored synthetic recovery strings. Do NOT fall back to
// provider_details_label — a genuine "Response interrupted" card the user
// SHOULD see also carries the 'Interruption details' label, and filtering
// on it would drop a real interruption from the transcript (the inverse
// data-loss class flagged on the sibling #3300). Marker + strict text only.
const text=String(typeof msgContent==='function'?msgContent(m):(m.content||''));
return _streamRecoveryControlMessageText(text);
}
function _filterRecoveryControlMessages(messages){
if(!Array.isArray(messages)) return [];
return messages.filter((m)=>!_streamRecoveryControlMessage(m));
}
function _replaceMarkerOnlyAssistantWithStreamError(messages){
if(!Array.isArray(messages)) return false;
const msg=[...messages].reverse().find(m=>m&&m.role==='assistant');
if(!_isMarkerOnlyAssistantMessage(msg)) return false;
msg.content='**Error:** No response received after context compression. Please retry.';
msg.provider_details='The only assistant text returned for this turn was the internal preserved-task-list compression marker, so the WebUI replaced it with an explicit error instead of rendering the marker as a model response.';
return true;
}
function _isTerminalStreamErrorMarkerMessage(message){
return message&&message.role==='assistant'&&typeof message.content==='string'&&
message.content.startsWith('**Connection interrupted:** The browser lost the live SSE connection before the response finished.');
}
function _ensureSingleTerminalStreamErrorMarker(messages){
if(!Array.isArray(messages)) return;
while(messages.length && _isTerminalStreamErrorMarkerMessage(messages[messages.length-1])){
messages.pop();
}
messages.push({
role:'assistant',
content:'**Connection interrupted:** The browser lost the live SSE connection before the response finished. If the worker completed, reopening this session should restore the settled transcript.',
});
}
function _setActivePaneIdleIfOwner(){
if(_isActiveSession()||!S.session||!INFLIGHT[S.session.session_id]){
setBusy(false);
setComposerStatus('');
if(typeof setStatus==='function') setStatus('');
}
}
function persistInflightState(){
const inflight=INFLIGHT[activeSid];
if(!inflight||typeof saveInflightState!=='function') return;
saveInflightState(activeSid,{
streamId,
messages:inflight.messages||[],
uploaded:inflight.uploaded||[...uploaded],
toolCalls:inflight.toolCalls||[],
lastAssistantText:inflight.lastAssistantText||'',
lastReasoningText:inflight.lastReasoningText||'',
lastRunJournalSeq:inflight.lastRunJournalSeq||0,
lastRunJournalEventId:inflight.lastRunJournalEventId||'',
journalReplayFromStart:!!inflight.journalReplayFromStart,
anchorActivityScene:inflight.anchorActivityScene||null,
currentActivityBurstId:inflight.currentActivityBurstId||0,
currentLiveSegmentSeq:inflight.currentLiveSegmentSeq||0,
activityBurstAnchors:Array.isArray(inflight.activityBurstAnchors)?inflight.activityBurstAnchors:[],
todos:Array.isArray(inflight.todos)?inflight.todos:S.todos,
todoStateMeta:inflight.todoStateMeta||S.todoStateMeta||null,
});
}
function snapshotLiveTurn(){
if(typeof snapshotLiveTurnHtmlForSession==='function') snapshotLiveTurnHtmlForSession(activeSid);
}
// Throttled per-frame variant. snapshotLiveTurnHtmlForSession serializes the
// whole (growing) live turn via turn.outerHTML — O(n)/frame -> O(n^2) over a
// long answer, and a real GC-pressure source. The snapshot only backs
// mid-stream session-switch restore, and the switch path (sessions.js) plus
// the stream event boundaries (tool/done) already capture synchronously, so a
// coarse trailing snapshot during streaming is sufficient. (#5455 WS2.2)
let _snapshotLiveTurnTimer=null;
function _throttledSnapshotLiveTurn(){
if(_snapshotLiveTurnTimer) return;
_snapshotLiveTurnTimer=setTimeout(()=>{_snapshotLiveTurnTimer=null;snapshotLiveTurn();},700);
}
function _cancelThrottledSnapshotTimer(){
if(_snapshotLiveTurnTimer){clearTimeout(_snapshotLiveTurnTimer);_snapshotLiveTurnTimer=null;}
}
// Throttled variant for token-by-token updates. persistInflightState()
// calls saveInflightState() which does JSON.parse + JSON.stringify + write
// on the entire inflight map every call. On a fast model at 60 tok/s with
// a 10KB messages array this is ~36MB of JSON churn per second — a major
// GC pressure source that causes the renderer to crash under load.
// State transitions (tool events, done, error) still call persistInflightState()
// directly so no more than 2s of progress is lost on a crash.
let _persistTimer=null;
function _throttledPersist(){
if(_persistTimer) return;
_persistTimer=setTimeout(()=>{_persistTimer=null;persistInflightState();},2000);
}
function _closeSource(source){
closeLiveStream(activeSid, streamId, source);
}
function _clearStreamEndRecovery(){
if(_streamEndRecoveryTimer){
clearTimeout(_streamEndRecoveryTimer);
_streamEndRecoveryTimer=null;
}
_pendingStreamEndRecovery=false;
_streamEndRecoveryAttempts=0;
}
function _liveStreamEndScenePresent(){
if(assistantText||assistantRow) return true;
if(String(liveReasoningText||reasoningText||'').trim()) return true;
const inflight=INFLIGHT[activeSid];
if(inflight&&Array.isArray(inflight.toolCalls)&&inflight.toolCalls.length) return true;
if(!_isActiveSession()||typeof document==='undefined') return false;
const turn=$('liveAssistantTurn');
return !!(turn&&turn.querySelector(
'[data-live-assistant="1"],'+
'.live-worklog[data-live-worklog-shell="1"],'+
'.tool-card-row[data-live-tid],'+
'.agent-activity-thinking[data-thinking-active="1"]'
));
}
function _scheduleStreamEndRecovery(source, delay=180){
if(_streamEndRecoveryTimer) clearTimeout(_streamEndRecoveryTimer);
_pendingStreamEndRecovery=true;
_streamEndRecoveryTimer=setTimeout(()=>{void _runStreamEndRecovery(source);},delay);
}
function _finalizeStreamEndFallback(source){
_clearStreamEndRecovery();
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_cancelThrottledSnapshotTimer();
_terminalStateReached=true;
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_clearStreamHidden(activeSid, streamId); // #4416: terminal path, drop hidden tracker
_clearStreamNotificationBackground(activeSid, streamId);
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup();
_clearAnchorProseIncrementalNode();
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
if(_isActiveSession()){
S.activeStreamId=null;
clearLiveToolCards();if(!assistantText)removeThinking();
renderMessages({preserveScroll:true});
}
renderSessionList();
_setActivePaneIdleIfOwner();
_closeSource(source);
}
async function _runStreamEndRecovery(source){
if(_streamFinalized || _terminalStateReached || !_pendingStreamEndRecovery){
_clearStreamEndRecovery();
return;
}
_streamEndRecoveryTimer=null;
const status=await _restoreSettledSession(source,{status:true});
if(status==='restored'){
_clearStreamEndRecovery();
return;
}
if(status==='active'&&_streamEndRecoveryAttempts<10){
_streamEndRecoveryAttempts+=1;
_scheduleStreamEndRecovery(source,200);
return;
}
_finalizeStreamEndFallback(source);
}
function _stripLiveVisibleAssistantEchoFromThinking(text, snippets){
let out=String(text||'');
(Array.isArray(snippets)?snippets:[]).forEach(snippet=>{
const visible=String(snippet||'').trim();
if(visible.length<20) return;
out=out.split(visible).join('');
});
return out.trim();
}
function _liveThinkingText(){
return String(liveReasoningText||'').trim() || 'Thinking…';
}
function _liveThinkingPlacement(){
const activeSeq=Number(_assistantSegmentSeq||0);
const nextSeq=Number(_currentLiveSegmentSeq||0)+1;
const segmentSeq=(!assistantRow||_freshSegment||!activeSeq)?nextSeq:activeSeq;
return {
activityKey:S.activeStreamId?'live:'+S.activeStreamId:null,
segmentSeq,
burstId:_currentActivityBurstId,
};
}
function _updateLiveThinkingCard(text, options){
const opts={
..._liveThinkingPlacement(),
...((options&&typeof options==='object')?options:{}),
};
if(typeof updateThinking==='function') updateThinking(text, opts);
else appendThinking(text, opts);
}
// Split a content string into {reasoning, content} by extracting any <think>...
// blocks (or other known reasoning-tag pairs). If reasoning is already
// populated on the message (e.g. from a separate on_reasoning stream), the
// inline blocks are stripped but the existing reasoning field is preserved.
// Provider-bug workaround: M3 (and similar reasoning models) emit the
// thinking inline in the OpenAI-compat content stream instead of a separate
// reasoning channel, which would otherwise bloat the persisted session
// message by 30-50% and miss the m.reasoning field used by the thinking card.
function _splitThinkFromContent(rawContent, existingReasoning){
return _extractInlineThinkingFromContent(rawContent, existingReasoning, {streaming:false});
}
function syncInflightAssistantMessage(){
const inflight=INFLIGHT[activeSid];
if(!inflight) return;
inflight.lastAssistantText=assistantText;
inflight.lastReasoningText=reasoningText;
if(!Array.isArray(inflight.messages)) inflight.messages=[];
let assistantIdx=-1;
for(let i=inflight.messages.length-1;i>=0;i--){
const msg=inflight.messages[i];
if(msg&&msg.role==='assistant'&&msg._live){assistantIdx=i;break;}
}
const ts=Date.now()/1000;
// Split inline <think> blocks into m.reasoning so the persisted inflight
// state stays compact and the thinking card has a proper source field.
const split=_splitThinkFromContent(assistantText, reasoningText);
if(assistantIdx>=0){
inflight.messages[assistantIdx].content=split.content;
inflight.messages[assistantIdx].reasoning=split.reasoning||undefined;
inflight.messages[assistantIdx]._ts=inflight.messages[assistantIdx]._ts||ts;
_throttledPersist();
return;
}
inflight.messages.push({role:'assistant',content:split.content,reasoning:split.reasoning||undefined,_live:true,_ts:ts});
_throttledPersist();
}
function recordActivityBoundary(){
const inflight=INFLIGHT[activeSid];
if(!inflight) return;
if(!Array.isArray(inflight.activityBurstAnchors)) inflight.activityBurstAnchors=[];
if(!assistantRow||!assistantRow.isConnected){
assistantRow=null;
assistantBody=null;
}
const textEnd=String(assistantText||'').length;
const lastTextEnd=inflight.activityBurstAnchors.reduce((max,a)=>{
const n=Number(a&&a.textEnd);
return Number.isFinite(n)?Math.max(max,n):max;
},0);
if(textEnd<=lastTextEnd){
inflight.currentActivityBurstId=_currentActivityBurstId;
if(assistantRow) assistantRow.setAttribute('data-activity-burst-id',String(_currentActivityBurstId));
persistInflightState();
return;
}
_currentActivityBurstId+=1;
inflight.currentActivityBurstId=_currentActivityBurstId;
const existing=inflight.activityBurstAnchors.find(a=>Number(a&&a.id)===_currentActivityBurstId);
if(existing) existing.textEnd=textEnd;
else inflight.activityBurstAnchors.push({id:_currentActivityBurstId,textEnd});
if(assistantRow) assistantRow.setAttribute('data-activity-burst-id',String(_currentActivityBurstId));
persistInflightState();
}
function ensureAssistantRow(force=false){
if(!_isActiveSession()) return;
if(assistantRow&&!assistantRow.isConnected){assistantRow=null;assistantBody=null;}
if(!force&&!assistantRow){
const parsed=_parseStreamState();
if(!String((parsed&&parsed.displayText)||'').trim()) return;
}
let turn=$('liveAssistantTurn');
if(!turn){
appendThinking();
turn=$('liveAssistantTurn');
}
const blocks=(typeof _assistantTurnBlocks==='function')?_assistantTurnBlocks(turn):null;
if(!blocks) return;
if(!assistantRow){
// After a tool call _freshSegment=true, so we always create a new segment
// below the tool card rather than re-attaching to the old one above it.
if(!_freshSegment){
const liveSegments=blocks.querySelectorAll('[data-live-assistant="1"]');
const existing=liveSegments.length?liveSegments[liveSegments.length-1]:null;
if(existing){
assistantRow=existing;
assistantBody=existing.querySelector('.msg-body');
const existingSeq=Number(existing.getAttribute('data-live-segment-seq')||'');
if(Number.isFinite(existingSeq)&&existingSeq>0){
_assistantSegmentSeq=existingSeq;
if(_assistantSegmentSeq>_currentLiveSegmentSeq) _currentLiveSegmentSeq=_assistantSegmentSeq;
}
}
}
}
if(assistantRow){
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd();
return;
}
const tr=$('toolRunningRow');if(tr)tr.remove();
$('emptyState').style.display='none';
assistantRow=document.createElement('div');
assistantRow.className='assistant-segment';
_currentLiveSegmentSeq+=1;
_assistantSegmentSeq=_currentLiveSegmentSeq;
assistantRow.setAttribute('data-live-assistant','1');
assistantRow.setAttribute('data-activity-burst-id',String(_currentActivityBurstId));
assistantRow.setAttribute('data-live-segment-seq',String(_assistantSegmentSeq));
assistantBody=document.createElement('div');assistantBody.className='msg-body';
assistantRow.appendChild(assistantBody);
blocks.appendChild(assistantRow);
if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd();
if(INFLIGHT[activeSid]){
INFLIGHT[activeSid].currentLiveSegmentSeq=_currentLiveSegmentSeq;
}
_freshSegment=false; // consumed — next reuse check is normal again
}
// ── Shared SSE handler wiring (used for initial connection and reconnect) ──
let _reconnectAttempted=false;
let _terminalStateReached=false;
let _deferredStreamRecoveryBound=false;
let _pendingStreamEndRecovery=false;
let _streamEndRecoveryTimer=null;
let _streamEndRecoveryAttempts=0;
function _pageHiddenForStreamError(){
return (typeof document!=='undefined'&&document.visibilityState==='hidden')||
(typeof document!=='undefined'&&document.wasDiscarded===true);
}
function _reattachOrRestoreAfterDeferredStreamError(source){
if(_terminalStateReached||_streamFinalized) return;
if((S.session&&S.session.session_id)!==activeSid) return;
(async()=>{
try{
if(streamId){
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
if(st.active){
setComposerStatus('Reconnected',1000);
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}${_runJournalReplayParams()}`,document.baseURI||location.href).href,{withCredentials:true}));
return;
}
}
}catch(_){
if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return;
}
if(await _restoreSettledSession(source, {preserveVisibleOnShorterTerminalSnapshot:true})) return;
if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source);
})();
}
function _deferStreamErrorIfPageHidden(source){
if(!_pageHiddenForStreamError()) return false;
setComposerStatus('Connection paused. Reconnecting when this tab returns…');
if(S.session&&S.session.session_id===activeSid&&streamId) S.activeStreamId=streamId;
if(!_deferredStreamRecoveryBound){
_deferredStreamRecoveryBound=true;
const resume=()=>{
if(_pageHiddenForStreamError()) return;
window.removeEventListener('focus',resume);
window.removeEventListener('pageshow',resume);
document.removeEventListener('visibilitychange',resume);
_deferredStreamRecoveryBound=false;
_reattachOrRestoreAfterDeferredStreamError(source);
};
document.addEventListener('visibilitychange',resume);
window.addEventListener('focus',resume);
window.addEventListener('pageshow',resume);
}
return true;
}
// Bug A fix (#631): track whether the stream has been finalized so any rAF
// scheduled by a trailing 'token'/'reasoning' event that arrives in the same
// microtask batch as 'done' does not fire after renderMessages() has already
// settled the DOM — which was causing the thinking card to reappear below
// the final answer or the response to render twice.
let _streamFinalized=false;
let _pendingRafHandle=null;
let _streamFadeVisibleText='';
let _streamFadeLastTickMs=0;
let _streamFadeWordCarry=0;
let _streamFadeStartedAt=0;
let _streamFadeLastTargetWords=0;
let _streamFadeLastArrivalMs=0;
let _streamFadeArrivalWps=0;
let _streamFadeLatestAnimationEndAt=0;
let _streamFadeVisibleWords=0;
let _streamFadeHoldUntilMs=0;
let _streamFadeCurrentMs=620;
let _streamFadeDomText='';
let _streamFadeSilentPrefixChars=0;
let _streamFadeReduceMotionMql=null;
let _streamFadeReduceMotion=false;
let _streamFadeReduceMotionOnChange=null;
let _currentActivityBurstId=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentActivityBurstId)||0)||0;
let _currentLiveSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
let _assistantSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
let _lastRunJournalSeq=reconnecting
? Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalSeq)||0)
: 0;
let _lastRunJournalEventId=reconnecting
? String((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId)||'')
: '';
const _STREAM_FADE_MS=620;
const _STREAM_FADE_MAX_MS=900;
const _STREAM_FADE_DONE_MAX_MS=1000;
const _STREAM_FADE_DONE_DRAIN_MAX_MS=1400;
const _anchorApi=(typeof window!=='undefined'&&window.HermesAssistantTurnAnchors)
? window.HermesAssistantTurnAnchors
: null;
const _anchorRegistryMap=(typeof window!=='undefined')
? (window._liveAnchorRegistries=window._liveAnchorRegistries||new Map())
: null;
const _existingAnchorRegistry=_anchorRegistryMap?_anchorRegistryMap.get(streamId):null;
const _anchorRegistry=_existingAnchorRegistry||(_anchorApi&&typeof _anchorApi.createAssistantTurnAnchorRegistry==='function'
? _anchorApi.createAssistantTurnAnchorRegistry({
session_id:activeSid,
stream_id:streamId,
run_id:null,
})
: null);
let _anchorShadowWarned=false;
let _anchorReasoningFlushed=false;
let _anchorLocalSeq=0;
if(_anchorRegistryMap&&_anchorRegistry) _anchorRegistryMap.set(streamId,_anchorRegistry);
function _scheduleAnchorRegistryCleanup(delayMs=600000){
if(!_anchorRegistryMap||!_anchorRegistry) return;
setTimeout(()=>{
if(_anchorRegistryMap.get(streamId)===_anchorRegistry) _anchorRegistryMap.delete(streamId);
},delayMs);
}
// Backstop: schedule an identity-guarded cleanup at creation so this shadow
// registry self-expires no matter which teardown path the stream takes
// (incl. external ones like sidebar cancelSessionStream() that bypass the
// in-closure SSE handlers). Explicit terminal-path calls above just expire it
// sooner; this guarantees window._liveAnchorRegistries can't grow unbounded.
_scheduleAnchorRegistryCleanup(600000);
// Applying an event and painting it are separate outcomes. Reasoning uses the
// optional holder to decide whether a temporary visible fallback is needed.
function _applyToAnchor(sourceEventType, rawEventData, sseEvent, renderOutcome, options={}){
if(renderOutcome&&typeof renderOutcome==='object') renderOutcome.rendered=false;
if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.applyAssistantTurnAnchorSourceEvent!=='function') return null;
const raw=(rawEventData&&typeof rawEventData==='object')?rawEventData:{};
const eventId=(sseEvent&&sseEvent.lastEventId)||raw.event_id||raw.lastEventId||raw.last_event_id||'';
const sourceEvent={
...raw,
source_event_type:sourceEventType,
// Persist a creation timestamp the FIRST time we see this source event, so
// the worklog event timestamp (#5700/#5739) survives settlement. Reasoning
// events carry no server timestamp; without this, the live DOM shows a
// fallback time but the settled scene row rebuilds with created_at:null and
// the timestamp disappears. Prefer any real server-supplied stamp; fall back
// to now only when none exists. (#5739 gate finding.)
created_at:raw.created_at??raw.timestamp??raw.ts??(Date.now()/1000),
activitySegmentSeq:raw.activitySegmentSeq??raw.activity_segment_seq??_assistantSegmentSeq,
activityBurstId:raw.activityBurstId??raw.activity_burst_id??_currentActivityBurstId,
};
if(eventId) sourceEvent.event_id=eventId;
try{
const result=_anchorApi.applyAssistantTurnAnchorSourceEvent(
_anchorRegistry,
sourceEvent,
{session_id:activeSid,stream_id:streamId}
);
const rendered=options&&options.render===false?false:_renderAnchorLiveScene();
if(renderOutcome&&typeof renderOutcome==='object') renderOutcome.rendered=rendered;
return result;
}catch(err){
if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){
_anchorShadowWarned=true;
console.warn('assistant turn anchor live shadow feed failed',err);
}
return null;
}
}
function _anchorActivityEvents(){
const anchor=_anchorRegistry&&_anchorRegistry.anchor;
return anchor&&Array.isArray(anchor.activity_events)?anchor.activity_events:null;
}
function _findAnchorActivityEventByLocalId(localId, sourceEventType){
const events=_anchorActivityEvents();
if(!events||!localId) return null;
for(let i=events.length-1;i>=0;i--){
const event=events[i];
if(!event||event.local_id!==localId) continue;
if(sourceEventType&&event.source_event_type!==sourceEventType) continue;
return event;
}
return null;
}
function _latestAnchorCompressionEventIndex(sourceEventType){
const events=_anchorActivityEvents();
if(!events) return -1;
for(let i=events.length-1;i>=0;i--){
const event=events[i];
if(event&&event.source_event_type===sourceEventType) return i;
}
return -1;
}
function _anchorCompressionCompletedAfter(index){
const events=_anchorActivityEvents();
if(!events) return false;
for(let i=events.length-1;i>index;i--){
const event=events[i];
if(event&&event.source_event_type==='compressed') return true;
}
return false;
}
function _ensureAnchorCompressionCompletedOnLiveProgress(sessionId){
if(!_anchorRegistry||!_anchorApi) return false;
const sid=String(sessionId||activeSid||'');
const events=_anchorActivityEvents();
const runningIndex=_latestAnchorCompressionEventIndex('compressing');
if(runningIndex>=0&&_anchorCompressionCompletedAfter(runningIndex)) return true;
const runningEvent=(events&&runningIndex>=0)?events[runningIndex]:null;
const basis=String((runningEvent&&(runningEvent.local_id||runningEvent.event_id))||streamId||sid||'compression');
const localId=`live-compression-complete:${basis}`;
if(_findAnchorActivityEventByLocalId(localId,'compressed')) return true;
const eventId=`synthetic:${localId}`;
const result=_applyToAnchor('compressed',{
event_id:eventId,
local_id:localId,
session_id:sid,
old_session_id:sid,
automatic:true,
synthetic:true,
status:'completed',
phase:'done',
message:'Context auto-compressed',
},{lastEventId:eventId});
return !!(result&&(result.applied||result.reason==='duplicate'));
}
function _replaceAnchorActivityEventByLocalId(localId, sourceEventType, patch){
const events=_anchorActivityEvents();
if(!events||!localId) return null;
for(let i=events.length-1;i>=0;i--){
const event=events[i];
if(!event||event.local_id!==localId) continue;
if(sourceEventType&&event.source_event_type!==sourceEventType) continue;
const next={
...event,
...(patch||{}),
payload:{
...(event.payload&&typeof event.payload==='object'?event.payload:{}),
...((patch&&patch.payload&&typeof patch.payload==='object')?patch.payload:{}),
},
};
events[i]=next;
return next;
}
return null;
}
function _nextAnchorLocalSeq(){
_anchorLocalSeq+=1;
const cursor=Number(_runJournalReplayAfterSeq&&_runJournalReplayAfterSeq());
return (Number.isFinite(cursor)?cursor:0)+_anchorLocalSeq;
}
function _anchorSegmentSeq(){
const seq=Number(_assistantSegmentSeq||_currentLiveSegmentSeq||0);
return Number.isFinite(seq)&&seq>0?seq:1;
}
function _anchorSceneActiveMode(){
const normalize=value=>value==='transparent_stream'||value==='compact_worklog'||value==='hide_all_activity'?value:'';
if(typeof window!=='undefined'){
if(typeof window.chatActivityMode==='function'){
try{
const mode=normalize(window.chatActivityMode());
if(mode) return mode;
}catch(_){}
}
const displayMode=normalize(window._chatActivityDisplayMode);
if(displayMode) return displayMode;
if(window._transparentStream) return 'transparent_stream';
}
return 'compact_worklog';
}
function _anchorSceneRowDisplayHintForMode(row, sceneMode){
const hints=row&&typeof row==='object'&&row.display_hints&&typeof row.display_hints==='object'
? row.display_hints
: null;
if(sceneMode==='transparent_stream') return (hints&&hints.transparent_stream)||'chronological_activity';
if(sceneMode==='compact_worklog') return (hints&&hints.compact_worklog)||row.display_hint||'activity_row';
if(sceneMode==='hide_all_activity') return (hints&&hints.hidden_activity)||'hidden_activity';
return row&&row.display_hint||'activity_row';
}
function _renderAnchorLiveScene(){
if(!_anchorRegistry||!_isActiveSession()) return false;
if(typeof window==='undefined'||typeof window._renderLiveAnchorActivitySceneForStream!=='function') return false;
try{
return !!window._renderLiveAnchorActivitySceneForStream(streamId, activeSid, {
mode:_anchorSceneActiveMode(),
});
}catch(err){
if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){
_anchorShadowWarned=true;
console.warn('assistant turn anchor live scene render failed',err);
}
return false;
}
}
function _projectLiveAnchorActivityScene(){
if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.projectAssistantTurnAnchorActivityScene!=='function') return null;
try{
return _anchorApi.projectAssistantTurnAnchorActivityScene(_anchorRegistry,{mode:_anchorSceneActiveMode()});
}catch(_){
return null;
}
}
function _anchorSceneMessageRef(message){
if(!message||typeof message!=='object') return '';
let content=message.content||'';
if(Array.isArray(content)){
try{
content=content.map(part=>{
if(part&&typeof part==='object') return part.text||part.content||part.input_text||'';
return String(part||'');
}).join('\n');
}catch(_){ content=''; }
}
const payload={
role:String(message.role||''),
content:String(content||'').replace(/\s+/g,' ').trim(),
timestamp:message._ts||message.timestamp||'',
};
return JSON.stringify(payload);
}
function _anchorSceneMessageText(message){
if(!message||typeof message!=='object') return '';
let content=message.content||'';
if(Array.isArray(content)){
try{
content=content.map(part=>{
if(part&&typeof part==='object') return part.text||part.content||part.input_text||'';
return String(part||'');
}).join('\n');
}catch(_){ content=''; }
}
return typeof content==='string'?content:String(content||'');
}
function _anchorSceneContentText(part){
if(part===undefined||part===null) return '';
if(typeof part==='string') return part;
if(typeof part!=='object') return String(part||'');
return String(part.text||part.content||part.input_text||part.output_text||part.thinking||part.reasoning||part.summary||'');
}
function _anchorSceneContentVisibleText(part){
if(part===undefined||part===null) return '';
if(typeof part==='string') return part;
if(typeof part!=='object') return String(part||'');
const partType=String(part.type||'');
if(partType==='thinking'||partType==='reasoning') return '';
const contentText=(partType==='text'||partType==='input_text'||partType==='output_text')?part.content:'';
return String(part.text||part.input_text||part.output_text||contentText||'');
}
function _anchorSceneMessageHasContentToolUse(message){
return !!(message&&Array.isArray(message.content)&&message.content.some(part=>part&&typeof part==='object'&&part.type==='tool_use'));
}
function _anchorSceneFinalAnswerText(message){
if(!_anchorSceneMessageHasContentToolUse(message)) return _anchorSceneMessageText(message);
const content=Array.isArray(message.content)?message.content:[];
let lastToolIndex=-1;
for(let i=0;i<content.length;i+=1){
const part=content[i];
if(part&&typeof part==='object'&&part.type==='tool_use') lastToolIndex=i;
}
const tailText=content.slice(lastToolIndex+1)
.map(part=>_anchorSceneContentVisibleText(part))
.filter(text=>_anchorSceneCleanText(text))
.join('\n');
return _anchorSceneCleanText(tailText)?tailText:'';
}
function _anchorSceneCleanText(value){
return String(value||'').replace(/\s+/g,' ').trim();
}
function _anchorSceneTextKey(value){
return _anchorSceneCleanText(value).toLowerCase();
}
function _anchorSceneSafePayload(value){
if(value===undefined) return undefined;
if(value===null||typeof value!=='object') return value;
try{
return JSON.parse(JSON.stringify(value));
}catch(_){
return String(value);
}
}
function _anchorSceneToolId(tool){
return String(tool&&(tool.tid||tool.id||tool.tool_call_id||tool.tool_use_id||tool.call_id)||'').trim();
}
function _anchorSceneToolName(tool){
const fn=tool&&tool.function&&typeof tool.function==='object'?tool.function:{};
return String(tool&&(tool.name||tool.tool_name)||fn.name||'tool').trim()||'tool';
}
function _anchorSceneToolArgs(tool){
if(!tool||typeof tool!=='object') return {};
if(tool.args&&typeof tool.args==='object') return _anchorSceneSafePayload(tool.args)||{};
if(tool.input&&typeof tool.input==='object') return _anchorSceneSafePayload(tool.input)||{};
const fn=tool.function&&typeof tool.function==='object'?tool.function:{};
if(typeof fn.arguments==='string'&&fn.arguments.trim()){
try{
const parsed=JSON.parse(fn.arguments);
return parsed&&typeof parsed==='object'?_anchorSceneSafePayload(parsed):{};
}catch(_){}
}
return {};
}
function _anchorSceneContentTool(part){
if(!part||typeof part!=='object') return {};
const fn=part.function&&typeof part.function==='object'?part.function:{};
return {
id:part.id||part.tid||part.tool_call_id||part.tool_use_id||part.call_id,
tid:part.tid||part.id||part.tool_call_id||part.tool_use_id||part.call_id,
tool_call_id:part.tool_call_id,
tool_use_id:part.tool_use_id,
call_id:part.call_id,
name:part.name||part.tool_name||fn.name||'tool',
tool_name:part.tool_name,
args:part.args,
input:part.input,
function:part.function,
command:part.command||part.raw_command||part.original_command||part.display_command,
preview:part.preview||part.summary,
snippet:part.snippet||part.result||part.output,
result:part.result,
output:part.output,
is_error:part.is_error,
error:part.error,
duration:part.duration,
started_at:part.started_at,
};
}
function _anchorSceneStringPayload(value){
if(value===undefined||value===null) return '';
if(typeof value==='string') return value;
try{
return JSON.stringify(value);
}catch(_){
return String(value);
}
}
function _anchorSceneRowBase(role, kind, sourceEventType, orderIndex, messageIndex){
const groupKey=Number.isFinite(Number(messageIndex))?`assistant:${Number(messageIndex)}`:`activity:${orderIndex}`;
return {
row_id:`settled:${activeSid||'session'}:${streamId||'stream'}:${role}:${messageIndex}:${orderIndex}`,
order_index:orderIndex,
kind,
role,
display_hint:role==='prose'?'main_prose':role==='thinking'?'collapsed_thinking':role==='tool'?'tool_row':role==='terminal'?'terminal_status_row':'activity_row',
display_hints:{
compact_worklog:role==='prose'?'main_prose':role==='thinking'?'collapsed_thinking':role==='tool'?'tool_row':role==='terminal'?'terminal_status_row':'activity_row',
transparent_stream:'chronological_activity',
},
source_event_type:sourceEventType,
event_id:null,
local_id:null,
run_id:null,
stream_id:streamId||null,
seq:orderIndex,
status:role==='terminal'?'completed':'completed',
created_at:null,
identity:{event_id:null,local_id:null,run_id:null,stream_id:streamId||null,seq:orderIndex},
group:{
group_key:groupKey,
activity_burst_id:null,
activity_segment_seq:null,
assistant_msg_idx:Number.isFinite(Number(messageIndex))?Number(messageIndex):null,
},
text:'',
thinking:null,
tool_call_id:null,
tool:null,
payload:{assistant_msg_idx:Number.isFinite(Number(messageIndex))?Number(messageIndex):null},
};
}
function _anchorSceneProseRow(text, orderIndex, messageIndex){
const row=_anchorSceneRowBase('prose','process_prose','settled_message',orderIndex,messageIndex);
row.text=String(text||'');
row.payload={...row.payload,text:row.text};
return row;
}
function _anchorSceneThinkingRow(text, orderIndex, messageIndex){
const row=_anchorSceneRowBase('thinking','reasoning','reasoning',orderIndex,messageIndex);
row.text=String(text||'');
const preview=_anchorSceneCleanText(text);
row.thinking={
text:row.text,
preview:preview.length>180?`${preview.slice(0,177)}...`:preview,
dedupe_key:preview?`thinking:${preview.toLowerCase()}`:'',
};
row.payload={...row.payload,text:row.text};
return row;
}
function _anchorSceneToolRowFromCall(tool, orderIndex, messageIndex){
const row=_anchorSceneRowBase('tool','tool_completed','tool_complete',orderIndex,messageIndex);
const tid=_anchorSceneToolId(tool);
const name=_anchorSceneToolName(tool);
const args=_anchorSceneToolArgs(tool);
const command=_anchorSceneStringPayload(tool&&(tool.command||tool.raw_command||tool.original_command||tool.display_command))||_anchorSceneStringPayload(args&&(args.cmd||args.command));
const preview=_anchorSceneStringPayload(tool&&(tool.preview||tool.summary));
const snippet=_anchorSceneStringPayload(tool&&(tool.snippet||tool.result||tool.output));
const isError=!!(tool&&(tool.is_error||tool.error));
row.row_id=tid?`settled:${activeSid||'session'}:${streamId||'stream'}:tool:${tid}`:row.row_id;
row.tool_call_id=tid||null;
row.tool={
id:tid||null,
name,
args,
command,
preview,
snippet,
result:_anchorSceneSafePayload(tool&&tool.result)??null,
output:_anchorSceneSafePayload(tool&&tool.output)??null,
done:true,
is_error:isError,
duration:tool&&tool.duration!==undefined?tool.duration:null,
started_at:tool&&tool.started_at!==undefined?tool.started_at:null,
signature:[name,tid||'',JSON.stringify(args||{})].join('|'),
};
row.payload={
...row.payload,
tid:tid||undefined,
id:tid||undefined,
name,
args,
command,
preview,
snippet,
is_error:isError,
duration:tool&&tool.duration!==undefined?tool.duration:undefined,
started_at:tool&&tool.started_at!==undefined?tool.started_at:undefined,
};
return row;
}
function _anchorSceneToolRowName(row){
const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{};
const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{};
return String(tool.name||payload.name||'tool').trim().toLowerCase();
}
function _anchorSceneToolRowId(row){
const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{};
const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{};
return String(
(row&&row.tool_call_id)||
tool.id||
tool.tid||
tool.tool_call_id||
tool.tool_use_id||
tool.call_id||
payload.tid||
payload.id||
''
).trim();
}
function _anchorSceneToolRowsHaveNonConflictingIds(existing, incoming){
const existingId=_anchorSceneToolRowId(existing);
const incomingId=_anchorSceneToolRowId(incoming);
return !existingId||!incomingId||existingId===incomingId;
}
function _anchorSceneToolRowsHaveDifferentExplicitIds(existing, incoming){
const existingId=_anchorSceneToolRowId(existing);
const incomingId=_anchorSceneToolRowId(incoming);
return !!existingId&&!!incomingId&&existingId!==incomingId;
}
function _anchorSceneToolRowStartedAt(row){
const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{};
const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{};
const value=tool.started_at!==undefined&&tool.started_at!==null&&tool.started_at!==''?tool.started_at:payload.started_at;
return value!==undefined&&value!==null&&value!==''?String(value):'';
}
function _anchorSceneToolRowsHaveSameStartedAt(existing, incoming){
const existingStartedAt=_anchorSceneToolRowStartedAt(existing);
const incomingStartedAt=_anchorSceneToolRowStartedAt(incoming);
return !!existingStartedAt&&!!incomingStartedAt&&existingStartedAt===incomingStartedAt;
}
function _anchorSceneToolRowBodyText(row){
const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{};
const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{};
for(const value of [tool.snippet,payload.snippet,tool.output,payload.output,tool.result,payload.result,tool.preview,payload.preview]){
const text=_anchorSceneStringPayload(value).trim();
if(text) return text;
}
return '';
}
function _anchorSceneToolRowsHaveCompatibleBody(existing, incoming){
const existingBody=_anchorSceneToolRowBodyText(existing);
const incomingBody=_anchorSceneToolRowBodyText(incoming);
return !!existingBody&&!!incomingBody&&(
existingBody===incomingBody||
existingBody.startsWith(incomingBody)||
incomingBody.startsWith(existingBody)
);
}
function _anchorSceneToolRowsHaveCompatibleNames(existing, incoming){
const existingName=_anchorSceneToolRowName(existing);
const incomingName=_anchorSceneToolRowName(incoming);
return !existingName||!incomingName||existingName==='tool'||incomingName==='tool'||existingName===incomingName;
}
function _anchorSceneToolRowArgs(row){
const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{};
const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{};
const args=(tool.args&&typeof tool.args==='object'&&!Array.isArray(tool.args))?tool.args:payload.args;
return args&&typeof args==='object'&&!Array.isArray(args)?args:null;
}
function _anchorSceneObjectContainsSubset(base, subset){
if(!base||!subset||typeof base!=='object'||typeof subset!=='object') return false;
const stableStringify=(candidate)=>{
const normalize=(value)=>{
if(!value||typeof value!=='object') return value;
if(Array.isArray(value)) return value.map(normalize);
const normalized={};
Object.keys(value).sort().forEach((key)=>{normalized[key]=normalize(value[key]);});
return normalized;
};
try{return JSON.stringify(normalize(candidate));}catch(_){return JSON.stringify(candidate);}
};
for(const [key,value] of Object.entries(subset)){
if(!Object.prototype.hasOwnProperty.call(base,key)) return false;
if(stableStringify(base[key])!==stableStringify(value)) return false;
}
return true;
}
function _anchorSceneToolRowsHaveCompatibleInvocation(existing, incoming){
const existingTool=existing&&existing.tool&&typeof existing.tool==='object'?existing.tool:{};
const incomingTool=incoming&&incoming.tool&&typeof incoming.tool==='object'?incoming.tool:{};
const existingPayload=existing&&existing.payload&&typeof existing.payload==='object'?existing.payload:{};
const incomingPayload=incoming&&incoming.payload&&typeof incoming.payload==='object'?incoming.payload:{};
const existingCommand=_anchorSceneStringPayload(existingTool.command||existingPayload.command).trim();
const incomingCommand=_anchorSceneStringPayload(incomingTool.command||incomingPayload.command).trim();
if(existingCommand&&incomingCommand) return existingCommand===incomingCommand;
const existingArgs=_anchorSceneToolRowArgs(existing);
const incomingArgs=_anchorSceneToolRowArgs(incoming);
if(!existingArgs||!incomingArgs||!Object.keys(existingArgs).length||!Object.keys(incomingArgs).length) return false;
return _anchorSceneObjectContainsSubset(existingArgs,incomingArgs)||_anchorSceneObjectContainsSubset(incomingArgs,existingArgs);
}
function _anchorSceneToolRowHasInvocationEvidence(row){
const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{};
const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{};
const command=_anchorSceneStringPayload(tool.command||payload.command).trim();
const args=_anchorSceneToolRowArgs(row);
return !!command||!!(args&&Object.keys(args).length);
}
function _anchorSceneToolRowsCanNameMatch(existing, incoming){
if(!_anchorSceneToolRowsHaveCompatibleNames(existing,incoming)) return false;
if(_anchorSceneToolRowHasInvocationEvidence(existing)&&_anchorSceneToolRowHasInvocationEvidence(incoming)){
return _anchorSceneToolRowsHaveCompatibleInvocation(existing,incoming);
}
return true;
}
function _anchorSceneMatchingContentToolRow(contentToolRows, incomingRow, ordinal, usedRows, incomingTotal, idFlexibleRows){
if(!Array.isArray(contentToolRows)||!incomingRow) return null;
const incomingTid=incomingRow.tool_call_id||(incomingRow.tool&&incomingRow.tool.id);
for(const row of contentToolRows){
if(!row||usedRows.has(row)) continue;
const tid=row.tool_call_id||(row.tool&&row.tool.id);
if(tid&&incomingTid&&tid===incomingTid) return row;
}
if(contentToolRows.length===1&&Number(incomingTotal)===1){
const onlyRow=contentToolRows[0];
if(onlyRow&&!usedRows.has(onlyRow)&&_anchorSceneToolRowsCanNameMatch(onlyRow,incomingRow)) return onlyRow;
}
const availableRows=contentToolRows.filter(row=>row&&!usedRows.has(row));
if(availableRows.length===1){
if(Number(incomingTotal)===1&&_anchorSceneToolRowsCanNameMatch(availableRows[0],incomingRow)) return availableRows[0];
if(
_anchorSceneToolRowsHaveCompatibleNames(availableRows[0],incomingRow)&&
_anchorSceneToolRowsHaveCompatibleInvocation(availableRows[0],incomingRow)
) return availableRows[0];
}
const reusableRows=contentToolRows.filter(row=>row&&usedRows.has(row));
if(
reusableRows.length===1&&
Number(incomingTotal)===1&&
(
(
_anchorSceneToolRowId(reusableRows[0])&&
_anchorSceneToolRowId(incomingRow)&&
_anchorSceneToolRowId(reusableRows[0])===_anchorSceneToolRowId(incomingRow)
)||
(
idFlexibleRows&&
idFlexibleRows.has(reusableRows[0])&&
_anchorSceneToolRowsHaveSameStartedAt(reusableRows[0],incomingRow)&&
_anchorSceneToolRowsHaveCompatibleBody(reusableRows[0],incomingRow)
)
)&&
_anchorSceneToolRowsHaveCompatibleNames(reusableRows[0],incomingRow)&&
_anchorSceneToolRowsHaveCompatibleInvocation(reusableRows[0],incomingRow)
) return reusableRows[0];
for(const row of contentToolRows){
if(!row||usedRows.has(row)) continue;
const tid=row.tool_call_id||(row.tool&&row.tool.id);
if(!tid&&!incomingTid&&_anchorSceneToolRowsCanNameMatch(row,incomingRow)) return row;
}
return null;
}
function _anchorSceneMessageReasoningText(message){
if(!message||typeof message!=='object') return '';
return String(message.reasoning||message._reasoning||message.reasoning_content||message.thinking||'');
}
function _anchorSceneRowsFromContentParts(message, messageIndex, options){
if(!_anchorSceneMessageHasContentToolUse(message)) return null;
options=(options&&typeof options==='object')?options:{};
const isFinalMessage=!!options.isFinalMessage;
const rows=[];
const content=Array.isArray(message.content)?message.content:[];
let lastToolIndex=-1;
for(let i=0;i<content.length;i+=1){
const part=content[i];
if(part&&typeof part==='object'&&part.type==='tool_use') lastToolIndex=i;
}
for(let i=0;i<content.length;i+=1){
const part=content[i];
if(!part||typeof part!=='object'){
if(isFinalMessage&&i>lastToolIndex) continue;
const text=_anchorSceneContentText(part);
if(_anchorSceneCleanText(text)) rows.push(_anchorSceneProseRow(text,rows.length,messageIndex));
continue;
}
if(part.type==='text'||part.type==='input_text'||part.type==='output_text'){
if(isFinalMessage&&i>lastToolIndex&&_anchorSceneContentVisibleText(part)) continue;
const text=_anchorSceneContentText(part);
if(_anchorSceneCleanText(text)) rows.push(_anchorSceneProseRow(text,rows.length,messageIndex));
continue;
}
if(part.type==='thinking'||part.type==='reasoning'){
const text=_anchorSceneContentText(part);
if(_anchorSceneCleanText(text)) rows.push(_anchorSceneThinkingRow(text,rows.length,messageIndex));
continue;
}
if(part.type==='tool_use'){
rows.push(_anchorSceneToolRowFromCall(_anchorSceneContentTool(part),rows.length,messageIndex));
}
}
return rows;
}
// #4622: a settled tool row built from messages[].tool_calls (state.db/sidecar)
// can lack the result body — terminal stdout, or the diff/output that a
// patch/edit card renders — because the persisted row carries only a short
// preview (or, on a cold/paginated load, nothing). The full body lives on the
// live S.toolCalls entry at settle time. When a settled row and a live call
// match by tool id, restore the missing body fields from the live call onto
// the settled row's tool+payload (only when the settled value is empty — never
// clobber a genuine persisted body), so the rebuilt card shows full output +
// the Show-more expander + the rendered diff. Returns true if it enriched.
function _enrichSettledToolRowBodyFromLive(row, live){
if(!row||typeof row!=='object'||!live||typeof live!=='object') return false;
const tool=(row.tool&&typeof row.tool==='object')?row.tool:(row.tool={});
const payload=(row.payload&&typeof row.payload==='object')?row.payload:(row.payload={});
let enriched=false;
const _empty=v=>v===undefined||v===null||v==='';
// Result body: _anchorSceneToolCallFromRow renders tool.snippet||payload.snippet
// (||payload.result||payload.output) as the card output + diff source, so
// restore the snippet onto both tool+payload when the settled row has none.
const liveSnippet=_anchorSceneStringPayload(live.snippet||live.result||live.output);
// Restore the live body when the settled snippet is missing OR is a bounded
// preview of the live one. The backend persists a capped preview
// (_TOOL_RESULT_SNIPPET_MAX = 4000 chars in api/streaming.py), so a long
// terminal/tool output settles to that 4000-char prefix, not to empty —
// #4622's actual symptom. Treat a settled snippet as restorable when the
// live snippet is strictly longer AND the settled value is a prefix of it
// AND the settled value is at/over the persistence cap (i.e. it's a
// truncated preview, not a genuinely short real value we must not clobber).
const _SETTLED_SNIPPET_CAP=4000;
const _isBoundedPreview=(settled,full)=>(
typeof settled==='string'&&typeof full==='string'&&
full.length>settled.length&&settled.length>=_SETTLED_SNIPPET_CAP&&
full.startsWith(settled)
);
const _settledSnippet=(!_empty(tool.snippet)?tool.snippet:(!_empty(payload.snippet)?payload.snippet:''));
const _snippetRestorable=(_empty(tool.snippet)&&_empty(payload.snippet))||_isBoundedPreview(_settledSnippet,liveSnippet);
if(liveSnippet&&_snippetRestorable){
tool.snippet=liveSnippet; payload.snippet=liveSnippet; enriched=true;
}
// Command (shell detail-lead) + args (diff/input reconstruction, the "Full" tab).
const liveCommand=_anchorSceneStringPayload(live.command||live.raw_command);
if(liveCommand&&_empty(tool.command)&&_empty(payload.command)){
tool.command=liveCommand; payload.command=liveCommand; enriched=true;
}
if(!_empty(live.started_at)&&_empty(tool.started_at)&&_empty(payload.started_at)){
tool.started_at=live.started_at; payload.started_at=live.started_at; enriched=true;
}
const liveArgs=_anchorSceneToolArgs(live);
if(liveArgs&&typeof liveArgs==='object'&&Object.keys(liveArgs).length){
const mergeMissingArgs=(existing)=>{
const base=(existing&&typeof existing==='object'&&!Array.isArray(existing))?{...existing}:{};
let changed=!(existing&&typeof existing==='object'&&!Array.isArray(existing));
for(const [key,value] of Object.entries(liveArgs)){
if(!Object.prototype.hasOwnProperty.call(base,key)){
base[key]=value;
changed=true;
}
}
return changed?base:existing;
};
const nextToolArgs=mergeMissingArgs(tool.args);
const nextPayloadArgs=mergeMissingArgs(payload.args);
if(nextToolArgs!==tool.args){ tool.args=nextToolArgs; enriched=true; }
if(nextPayloadArgs!==payload.args){ payload.args=nextPayloadArgs; enriched=true; }
}
return enriched;
}
function _anchorSceneRowsByMessageIndex(messages, turnStart, lastAsstIndex, options){
options=(options&&typeof options==='object')?options:{};
const byIdx=new Map();
const add=(idx,row)=>{
if(!byIdx.has(idx)) byIdx.set(idx,[]);
byIdx.get(idx).push(row);
};
// Pre-index S.toolCalls by assistant_msg_idx for O(m+n) lookup
const toolsByIdx=new Map();
if(S.toolCalls) for(const tc of S.toolCalls){
const ti=typeof tc.toolIdx==='number'? tc.toolIdx : parseInt(tc.assistant_msg_idx,10);
if(Number.isFinite(ti)){
if(!toolsByIdx.has(ti)) toolsByIdx.set(ti,[]);
toolsByIdx.get(ti).push(tc);
}
}
let encounter=0;
const endIndex=options&&options.includeFinal?lastAsstIndex+1:lastAsstIndex;
for(let idx=turnStart+1;idx<endIndex;idx+=1){
const message=messages[idx];
if(!message||message.role!=='assistant') continue;
const pool=[];
const text=_anchorSceneMessageText(message);
const contentRows=_anchorSceneRowsFromContentParts(message,idx,{isFinalMessage:idx===lastAsstIndex});
const hasOrderedContentRows=Array.isArray(contentRows)&&contentRows.length>0;
const contentToolRows=[];
const usedContentToolRows=new Set();
const idFlexibleContentToolRows=new Set();
const seenToolIds=new Set();
const rowByToolId=new Map();
if(hasOrderedContentRows){
for(const row of contentRows){
pool.push({...row,_phase:1,_encounter:encounter++,_fromContent:true});
const tid=row.tool_call_id||(row.tool&&row.tool.id);
if(tid){ seenToolIds.add(tid); rowByToolId.set(tid,row); }
if(row.role==='tool') contentToolRows.push(row);
}
}else if(_anchorSceneCleanText(text)){
pool.push({..._anchorSceneProseRow(text,0,idx),_phase:2,_encounter:encounter++});
}
const reasoning=_anchorSceneMessageReasoningText(message);
if(_anchorSceneCleanText(reasoning)&&_anchorSceneTextKey(reasoning)!==_anchorSceneTextKey(text)){
pool.push({..._anchorSceneThinkingRow(reasoning,0,idx),_phase:0,_encounter:encounter++});
}
const messageTools=[];
if(Array.isArray(message.tool_calls)) messageTools.push(...message.tool_calls);
if(Array.isArray(message._partial_tool_calls)) messageTools.push(...message._partial_tool_calls);
let messageToolOrdinal=0;
for(const tool of messageTools){
const row=_anchorSceneToolRowFromCall(tool,0,idx);
const tid=row.tool_call_id||(row.tool&&row.tool.id);
if(tid&&seenToolIds.has(tid)){
const existing=rowByToolId.get(tid);
if(existing){
_enrichSettledToolRowBodyFromLive(existing, tool);
if(contentToolRows.includes(existing)) usedContentToolRows.add(existing);
}
messageToolOrdinal+=1;
continue;
}
const contentMatch=_anchorSceneMatchingContentToolRow(contentToolRows,row,messageToolOrdinal,usedContentToolRows,messageTools.length,idFlexibleContentToolRows);
if(contentMatch){
if(_anchorSceneToolRowsHaveDifferentExplicitIds(contentMatch,row)) idFlexibleContentToolRows.add(contentMatch);
_enrichSettledToolRowBodyFromLive(contentMatch, tool);
if(tid){ seenToolIds.add(tid); rowByToolId.set(tid,contentMatch); }
usedContentToolRows.add(contentMatch);
messageToolOrdinal+=1;
continue;
}
pool.push({...row,_phase:1,_encounter:encounter++});
if(tid){ seenToolIds.add(tid); rowByToolId.set(tid,row); }
messageToolOrdinal+=1;
}
// Merge S.toolCalls for this index, dedup by tool id. When a live call
// matches a settled row already in the pool, don't just skip it —
// restore any result body the settled row is missing (#4622): the live
// S.toolCalls entry carries the full terminal output / patch diff that the
// persisted state.db row may have dropped to a short preview or nothing.
let liveToolOrdinal=0;
for(const tool of (toolsByIdx.get(idx)||[])){
if(!tool||typeof tool!=='object') continue;
const toolIdx=Number(tool.assistant_msg_idx);
if(!Number.isFinite(toolIdx)||toolIdx!==idx) continue;
const row=_anchorSceneToolRowFromCall(tool,0,idx);
const tid=row.tool_call_id||(row.tool&&row.tool.id);
if(tid&&seenToolIds.has(tid)){
const existing=rowByToolId.get(tid);
if(existing){
_enrichSettledToolRowBodyFromLive(existing, tool);
if(contentToolRows.includes(existing)) usedContentToolRows.add(existing);
}
liveToolOrdinal+=1;
continue;
}
const liveTools=toolsByIdx.get(idx)||[];
const contentMatch=_anchorSceneMatchingContentToolRow(contentToolRows,row,liveToolOrdinal,usedContentToolRows,liveTools.length,idFlexibleContentToolRows);
if(contentMatch){
if(_anchorSceneToolRowsHaveDifferentExplicitIds(contentMatch,row)) idFlexibleContentToolRows.add(contentMatch);
_enrichSettledToolRowBodyFromLive(contentMatch, tool);
if(tid){ seenToolIds.add(tid); rowByToolId.set(tid,contentMatch); }
usedContentToolRows.add(contentMatch);
liveToolOrdinal+=1;
continue;
}
if(tid){ seenToolIds.add(tid); rowByToolId.set(tid,row); }
pool.push({...row,_phase:1,_encounter:encounter++});
liveToolOrdinal+=1;
}
// Stable sort by (phase, started_at, encounter). Once a message has an
// ordered content[] scene, preserve that content bucket order exactly.
const useStartedAt=!hasOrderedContentRows;
pool.sort((a,b)=>{
if(a._phase!==b._phase) return a._phase-b._phase;
if(useStartedAt){
const aTime=(a.tool&&a.tool.started_at!=null)?a.tool.started_at:Infinity;
const bTime=(b.tool&&b.tool.started_at!=null)?b.tool.started_at:Infinity;
if(aTime!==bTime) return aTime-bTime;
}
return a._encounter-b._encounter;
});
// Emit with sequential order_index values, strip temp props.
// Rows were built with orderIndex=0, so their row_id/seq still encode 0.
// Rewrite order_index AND regenerate the index-derived identity fields
// (row_id/seq) from the final per-bucket position, so two anonymous rows
// (no tool id) at the same message index don't collide on the same row_id
// and get silently deduped by _completeSettledAnchorSceneForTurn().
for(const row of pool){
const {_phase,_encounter,_fromContent,...clean}=row;
const oi=byIdx.has(idx)?byIdx.get(idx).length:0;
clean.order_index=oi;
clean.seq=oi;
if(clean.identity&&typeof clean.identity==='object') clean.identity={...clean.identity,seq:oi};
// Tool rows with a tool id carry a tid-based row_id (already unique) —
// only regenerate the default index-based row_id form.
const indexRowId=`settled:${activeSid||'session'}:${streamId||'stream'}:${clean.role}:${idx}:0`;
if(clean.row_id===indexRowId){
clean.row_id=`settled:${activeSid||'session'}:${streamId||'stream'}:${clean.role}:${idx}:${oi}`;
}
add(idx,clean);
}
}
return byIdx;
}
function _anchorSceneExistingRowKey(row){
if(!row||typeof row!=='object') return '';
if(row.role==='tool'){
const tool=row.tool&&typeof row.tool==='object'?row.tool:{};
return `tool:${row.tool_call_id||tool.id||tool.tid||tool.tool_call_id||tool.tool_use_id||tool.call_id||row.row_id||''}`;
}
if(row.role==='prose'||row.role==='thinking') return `${row.role}:${_anchorSceneTextKey(row.text)}`;
return `${row.role||row.kind}:${row.source_event_type||''}:${row.status||''}:${row.row_id||''}`;
}
function _anchorSceneRowHasLiveIdentity(row){
if(!row||typeof row!=='object') return false;
const identity=row.identity&&typeof row.identity==='object'?row.identity:{};
const values=[row.row_id,row.local_id,row.event_id,identity.local_id,identity.event_id];
if(values.some(value=>String(value||'').startsWith('live-'))) return true;
// Provider tool-call IDs do not use the live prefix. A stream owner without
// a settled assistant index still identifies a projected live row.
const group=row.group&&typeof row.group==='object'?row.group:{};
const hasStreamOwner=!!(row.stream_id||row.run_id||identity.stream_id||identity.run_id);
const hasAssistantMessageIndex=group.assistant_msg_idx!==undefined&&group.assistant_msg_idx!==null;
return hasStreamOwner&&!hasAssistantMessageIndex;
}
function _anchorSceneMessageRowsHaveThinking(messageRows){
if(!(messageRows instanceof Map)) return false;
for(const bucket of messageRows.values()){
if(Array.isArray(bucket)&&bucket.some(row=>row&&row.role==='thinking')) return true;
}
return false;
}
function _anchorSceneSettleLiveRunningRow(row, hasSettledThinking){
if(!row||typeof row!=='object') return row;
if(row.role!=='thinking'&&row.role!=='prose'&&row.role!=='tool') return row;
if(String(row.status||'').toLowerCase()!=='running') return row;
if(!_anchorSceneRowHasLiveIdentity(row)) return row;
if(row.role==='thinking'&&hasSettledThinking) return null;
const sealed={...row,status:'completed'};
if(row.payload&&typeof row.payload==='object'){
sealed.payload={...row.payload,status:'completed'};
if(row.role==='tool') sealed.payload.done=true;
}
if(row.role==='tool'&&row.tool&&typeof row.tool==='object'){
sealed.tool={...row.tool,done:true};
}
return sealed;
}
function _anchorSceneRowLooksLikeFinalAnswer(rowTextKey, finalKey){
if(!rowTextKey||!finalKey) return false;
if(rowTextKey===finalKey) return true;
// #4587: align with the renderer's _anchorSceneProseMatchesFinalAnswer — a
// prefix-like overlap only counts as "the final answer" (and is dropped from
// the scene) when it's a NEAR-complete match (ratio>=0.9). A shorter
// intermediate-prose row that merely happens to be a prefix of the final
// answer is legitimate progress narration and must be PRESERVED, not dropped.
if(!(finalKey.startsWith(rowTextKey)||rowTextKey.startsWith(finalKey))) return false;
const shorter=Math.min(rowTextKey.length,finalKey.length);
const longer=Math.max(rowTextKey.length,finalKey.length);
return shorter>=80&&longer>0&&(shorter/longer)>=0.9;
}
function _anchorSceneRowTextOverlapsExisting(rowTextKey, seenTextKeys){
if(!rowTextKey||!Array.isArray(seenTextKeys)) return false;
for(const existing of seenTextKeys){
if(!existing) continue;
if(rowTextKey===existing) return true;
const minLen=Math.min(rowTextKey.length,existing.length);
if(minLen>=80&&(rowTextKey.includes(existing)||existing.includes(rowTextKey))) return true;
}
return false;
}
function _anchorSceneTurnDurationForSettlement(lastAsst, base){
if(lastAsst&&lastAsst._turnDuration!==undefined&&lastAsst._turnDuration!==null) return lastAsst._turnDuration;
if(base&&base.turn_duration!==undefined&&base.turn_duration!==null) return base.turn_duration;
const session=(typeof S!=='undefined'&&S&&S.session)?S.session:null;
// The `pending_started_at` fallback below is the START of an IN-FLIGHT turn.
// For a SETTLED turn that recorded no live duration, computing
// `now - pending_started_at` is wrong: pending_started_at is either stale
// (left over from an earlier turn / a session that sat idle) or belongs to a
// different, still-pending turn — which rendered a bogus "Processed 15h 32m"
// on fresh conversations (#4930). Only use it while a turn is actually in
// flight; otherwise show no duration rather than a fabricated one.
const turnInFlight=!!(session&&(session.active_stream_id||session.pending_user_message));
if(!turnInFlight) return undefined;
const candidates=[
session&&session.pending_started_at,
session&&session.active_started_at,
session&&session.run_started_at,
session&&session.started_at,
];
for(const raw of candidates){
const started=Number(raw);
if(Number.isFinite(started)&&started>0){
const elapsed=(Date.now()/1000)-started;
if(Number.isFinite(elapsed)&&elapsed>=0) return elapsed;
}
}
return undefined;
}
function _completeSettledAnchorSceneForTurn(messages, lastAsstIndex, projectedScene){
if(!Array.isArray(messages)||lastAsstIndex<0) return projectedScene;
const lastAsst=messages[lastAsstIndex];
if(!lastAsst||lastAsst.role!=='assistant') return projectedScene;
let turnStart=-1;
for(let idx=lastAsstIndex-1;idx>=0;idx-=1){
if(messages[idx]&&messages[idx].role==='user'){
turnStart=idx;
break;
}
}
const base=(projectedScene&&typeof projectedScene==='object')?projectedScene:{};
const sceneMode=base.mode==='transparent_stream'||base.mode==='hide_all_activity' ? base.mode : _anchorSceneActiveMode();
const messageFinalAnswer=_anchorSceneFinalAnswerText(lastAsst);
const finalAnswer=_anchorSceneCleanText(messageFinalAnswer)
? messageFinalAnswer
: (typeof base.final_answer==='string'?base.final_answer:'');
const finalKey=_anchorSceneTextKey(finalAnswer);
const messageRows=_anchorSceneRowsByMessageIndex(messages,turnStart,lastAsstIndex,{includeFinal:true});
const hasSettledThinking=_anchorSceneMessageRowsHaveThinking(messageRows);
const rows=[];
const seen=new Set();
const seenTextKeys=[];
const projectedRows=Array.isArray(base.activity_rows)?base.activity_rows:[];
const orderedRows=[];
for(const row of projectedRows){
if(row&&row.role==='terminal') continue;
orderedRows.push(row);
}
for(let idx=turnStart+1;idx<=lastAsstIndex;idx+=1){
const bucket=messageRows.get(idx)||[];
for(const row of bucket) orderedRows.push(row);
}
for(const row of projectedRows){
if(row&&row.role==='terminal') orderedRows.push(row);
}
// #5758 gap: final-segment eligibility must be judged against the LIVE
// projection's own chronology. The settled per-message tool rows appended
// into orderedRows above re-list tools that ran EARLIER in the turn, so an
// index over the combined list pushes the "after the last tool row"
// boundary past the final segment's live-prose accumulator — its stale
// prefix snapshot then survives into the persisted scene and renders as a
// duplicate of the answer's beginning. A live-prose row belongs to the
// final segment iff no PROJECTED tool row follows it; pre-tool narration
// that happens to prefix the final answer stays protected.
const lastProjectedToolIndex=projectedRows.reduce((last,row,idx)=>(row&&row.role==='tool')?idx:last,-1);
const finalSegmentLiveProseRows=new WeakSet();
projectedRows.forEach((row,idx)=>{
if(idx>lastProjectedToolIndex&&row&&row.role==='prose'&&row.kind==='process_prose'&&String(row.source_event_type||'')==='token'&&String(row.local_id||'').startsWith('live-prose:')) finalSegmentLiveProseRows.add(row);
});
const rowIsLiveTokenFinalPrefix=(row,textKey,finalSegmentEligible)=>finalSegmentEligible&&row&&row.role==='prose'&&row.kind==='process_prose'&&String(row.source_event_type||'')==='token'&&String(row.local_id||'').startsWith('live-prose:')&&textKey&&finalKey&&textKey.length<finalKey.length&&finalKey.startsWith(textKey);
const pushRow=(row)=>{
if(!row||typeof row!=='object') return;
const finalSegmentEligible=finalSegmentLiveProseRows.has(row);
row=_anchorSceneSettleLiveRunningRow(row,hasSettledThinking);
if(!row||typeof row!=='object') return;
const textKey=_anchorSceneTextKey(row.text);
if(rowIsLiveTokenFinalPrefix(row,textKey,finalSegmentEligible)) return;
const isTextual=row.role==='prose'||row.role==='thinking';
if(isTextual&&_anchorSceneRowLooksLikeFinalAnswer(textKey,finalKey)) return;
if(isTextual&&_anchorSceneRowTextOverlapsExisting(textKey,seenTextKeys)) return;
const key=_anchorSceneExistingRowKey(row);
if(key&&seen.has(key)) return;
if(key) seen.add(key);
if(isTextual&&textKey) seenTextKeys.push(textKey);
rows.push({
...row,
display_hint:_anchorSceneRowDisplayHintForMode(row,sceneMode),
order_index:rows.length,
seq:rows.length,
});
};
orderedRows.forEach((row)=>pushRow(row));
const scene={
...base,
version:'activity_scene_v1',
mode:sceneMode,
identity:{
...((base.identity&&typeof base.identity==='object')?base.identity:{}),
source_message_refs:messages.slice(turnStart+1,lastAsstIndex+1)
.filter(m=>m&&m.role==='assistant')
.map(m=>_anchorSceneMessageRef(m)),
},
lifecycle:(base.lifecycle&&typeof base.lifecycle==='object')?{...base.lifecycle}:{},
final_answer:_anchorSceneCleanText(finalAnswer)?finalAnswer:'',
final_message_ref:_anchorSceneMessageRef(lastAsst),
turn_duration:_anchorSceneTurnDurationForSettlement(lastAsst,base),
terminal_state:base.terminal_state||((base.lifecycle&&base.lifecycle.terminal_state)||null),
activity_rows:rows,
};
return scene;
}
let _persistAnchorSceneWarned=false;
function _anchorSceneMessageOffsetForPersist(){
const raw=(typeof _oldestIdx!=='undefined')?_oldestIdx:0;
const offset=Number(raw);
return Number.isFinite(offset)&&offset>0?Math.floor(offset):0;
}
function _anchorSceneAbsoluteMessageIndexForPersist(messageIndex, offset){
const idx=Number(messageIndex);
const off=Number(offset);
if(!Number.isFinite(idx)||idx<0) return messageIndex;
return idx+(Number.isFinite(off)&&off>0?Math.floor(off):0);
}
function _persistSettledAnchorScene(message, scene, messageIndex){
if(!activeSid||!message||!scene||typeof api!=='function') return;
try{
const messageOffset=_anchorSceneMessageOffsetForPersist();
api('/api/session/anchor-scene',{
method:'POST',
timeoutMs:8000,
timeoutToast:false,
body:JSON.stringify({
session_id:activeSid,
stream_id:streamId,
message_index:_anchorSceneAbsoluteMessageIndexForPersist(messageIndex,messageOffset),
message_window_index:messageIndex,
message_offset:messageOffset,
message_ref:_anchorSceneMessageRef(message),
scene,
}),
}).catch(err=>{
if(!_persistAnchorSceneWarned&&typeof console!=='undefined'&&console.warn){
_persistAnchorSceneWarned=true;
console.warn('anchor activity scene persistence failed',err);
}
});
}catch(err){
if(!_persistAnchorSceneWarned&&typeof console!=='undefined'&&console.warn){
_persistAnchorSceneWarned=true;
console.warn('anchor activity scene persistence failed',err);
}
}
}
function _anchorSceneHasWorklogWorthyRows(scene){
if(scene&&scene.mode==='hide_all_activity') return false;
if(typeof window!=='undefined'&&typeof window.isFinalAnswerOnlyMode==='function'&&window.isFinalAnswerOnlyMode()) return false;
// A worklog (the collapsible "已处理 …" rail) is only meaningful when the turn
// actually DID worklog-worthy work — a tool call, a thinking/reasoning pass, or
// a compression lifecycle card. A turn that only streamed prose (e.g. a long
// plain-text answer, or a degeneration burst that flooded the body with repeated
// tokens) projects an activity scene whose rows are ALL `prose`/`terminal`. Folding
// such a turn into a collapsed worklog hides the whole answer and, at STREAM_DONE,
// shrinks the transcript by the full streamed height → the browser clamps a
// bottom-pinned viewport back to the top (the "jump back" report). Require at least
// one genuinely worklog-worthy row before promoting the turn to a worklog.
const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[];
for(const row of rows){
if(!row||typeof row!=='object') continue;
const role=String(row.role||'');
if(role==='tool'||role==='thinking') return true;
if(role==='lifecycle'){
const source=String(row.source_event_type||'');
// compression cards are worklog-worthy; a bare terminal/done lifecycle is not.
if(source==='compressing'||source==='compressed') return true;
}
}
return false;
}
function _anchorSceneHasOwnedOutcomes(scene){
return !!(
(Array.isArray(scene&&scene.artifacts)&&scene.artifacts.length)
|| (Array.isArray(scene&&scene.side_effects)&&scene.side_effects.length)
);
}
function _attachProjectedAnchorSceneToLastAssistant(messages, targetMessage=null, targetIndex=null){
if(!_anchorRegistry||!Array.isArray(messages)) return false;
let lastAsst=targetMessage;
let lastAsstIndex=Number.isInteger(targetIndex)?targetIndex:-1;
if(lastAsst){
if(lastAsstIndex<0||messages[lastAsstIndex]!==lastAsst) return false;
}else{
for(let i=messages.length-1;i>=0;i--){
const candidate=messages[i];
if(candidate&&candidate.role==='assistant'){
lastAsst=candidate;
lastAsstIndex=i;
break;
}
}
}
if(!lastAsst) return false;
const projectedScene=_projectLiveAnchorActivityScene();
const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene);
const hasOwnedOutcomes=_anchorSceneHasOwnedOutcomes(scene);
if(scene&&Array.isArray(scene.activity_rows)&&(scene.activity_rows.length||hasOwnedOutcomes)){
const hasWorklogRows=_anchorSceneHasWorklogWorthyRows(scene);
const shouldPersistScene=hasWorklogRows||scene.mode==='hide_all_activity'||hasOwnedOutcomes;
if(!shouldPersistScene) return false;
let sceneKey='';
try{ sceneKey=JSON.stringify(scene); }catch(_){ sceneKey=''; }
if(
sceneKey &&
lastAsst._anchor_stream_id===streamId &&
lastAsst._anchor_scene_persist_key===sceneKey
) return hasWorklogRows;
lastAsst._anchor_stream_id=streamId;
lastAsst._anchor_activity_scene=scene;
lastAsst._anchor_scene_persist_key=sceneKey;
_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex);
return hasWorklogRows;
}
return false;
}
function _settledAnchorRetryOwnerKey(messages, targetIndex, retryStreamId){
if(!Array.isArray(messages)||!Number.isInteger(targetIndex)) return '';
const target=messages[targetIndex];
if(!target||target.role!=='assistant') return '';
let turnStart=0;
for(let idx=targetIndex-1;idx>=0;idx-=1){
if(messages[idx]&&messages[idx].role==='user'){
turnStart=idx;
break;
}
}
let hasStableOwnerSignal=false;
const ownerRows=[];
const addUnique=(items,value)=>{
const normalized=String(value||'').trim();
if(normalized&&!items.includes(normalized)) items.push(normalized);
};
for(let idx=turnStart;idx<=targetIndex;idx+=1){
const message=messages[idx];
if(!message||typeof message!=='object') return '';
const explicitIds=[];
for(const field of ['id','message_id','turn_id','_turn_id','run_id','_run_id']){
addUnique(explicitIds,message[field]);
}
const toolCallIds=[];
addUnique(toolCallIds,message.tool_call_id);
const addToolOwner=(tool)=>{
if(!tool||typeof tool!=='object') return;
addUnique(toolCallIds,tool.id||tool.tid||tool.tool_call_id||tool.tool_use_id||tool.call_id);
};
for(const tool of (Array.isArray(message.tool_calls)?message.tool_calls:[])) addToolOwner(tool);
for(const tool of (Array.isArray(message._partial_tool_calls)?message._partial_tool_calls:[])) addToolOwner(tool);
for(const part of (Array.isArray(message.content)?message.content:[])) addToolOwner(part);
explicitIds.sort();
toolCallIds.sort();
if(explicitIds.length||toolCallIds.length) hasStableOwnerSignal=true;
ownerRows.push({
message_ref:_anchorSceneMessageRef(message),
reasoning:String(message.reasoning||message._reasoning||message.reasoning_content||message.thinking||'').replace(/\s+/g,' ').trim(),
partial:!!message._partial,
explicit_ids:explicitIds,
tool_call_ids:toolCallIds,
});
}
if(!hasStableOwnerSignal) return '';
return JSON.stringify({
session_id:activeSid||'',
stream_id:String(retryStreamId||''),
target_index:targetIndex,
messages:ownerRows,
});
}
function _retrySettledAnchorScene(targetMessage, targetIndex, retryStreamId, retryRegistry, retryOwnerKey){
if(!targetMessage||!Number.isInteger(targetIndex)) return false;
if(!S.session||S.session.session_id!==activeSid) return false;
if(S.activeStreamId&&S.activeStreamId!==retryStreamId) return false;
if(!_anchorRegistryMap||_anchorRegistryMap.get(retryStreamId)!==retryRegistry) return false;
if(!Array.isArray(S.messages)) return false;
const currentTarget=S.messages[targetIndex];
if(currentTarget!==targetMessage){
const currentOwnerKey=_settledAnchorRetryOwnerKey(S.messages,targetIndex,retryStreamId);
if(!retryOwnerKey||!currentOwnerKey||currentOwnerKey!==retryOwnerKey) return false;
if(targetMessage._anchor_stream_id===retryStreamId){
if(currentTarget._anchor_stream_id==null) currentTarget._anchor_stream_id=targetMessage._anchor_stream_id;
if(currentTarget._anchor_scene_persist_key==null) currentTarget._anchor_scene_persist_key=targetMessage._anchor_scene_persist_key;
if(!currentTarget._anchor_activity_scene&&targetMessage._anchor_activity_scene){
currentTarget._anchor_activity_scene=targetMessage._anchor_activity_scene;
}
}
}
return _attachProjectedAnchorSceneToLastAssistant(S.messages,currentTarget,targetIndex);
}
function _upsertAnchorProcessProse(displayText, options={}){
const text=String(displayText||'').trim();
if(!text||!_anchorRegistry) return null;
const segmentSeq=Number(options.segmentSeq||_anchorSegmentSeq());
const localId=`live-prose:${streamId}:${segmentSeq}`;
const existing=_findAnchorActivityEventByLocalId(localId,'token');
if(existing){
const replaced=_replaceAnchorActivityEventByLocalId(localId,'token',{
status:options.sealed?'completed':'running',
payload:{text,activitySegmentSeq:segmentSeq,activityBurstId:_currentActivityBurstId},
});
_renderAnchorLiveScene();
return replaced;
}
_applyToAnchor('token',{
text,
local_id:localId,
seq:_nextAnchorLocalSeq(),
status:options.sealed?'completed':'running',
activitySegmentSeq:segmentSeq,
activityBurstId:_currentActivityBurstId,
},null);
return _findAnchorActivityEventByLocalId(localId,'token');
}
// Persistent incremental renderer for anchor-scene live prose rows. The compact
// worklog re-renders the whole scene each frame; rendering the growing prose via
// renderMd(fullText) every frame is O(n^2) over a long answer. Instead keep a
// per-segment smd parser + node (the SAME safe renderer as the main live body)
// and feed only the delta, then hand the persistent node back to the ui.js scene
// builder. Returns null whenever smd or a stable key is unavailable so the caller
// falls back to the full renderMd path — identical structure, just not
// incremental. (#5455 WS2.1)
const _anchorProseSmdCache = new Map();
function _finalizeAnchorProseIncrementalNode(st){
if(!st || !st.parser || st.finalized) return;
const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body');
window.smd.parser_end(st.parser);
if(body){
if(typeof _smdMediaTailFlush === 'function') _smdMediaTailFlush(st.parser);
if(typeof _sanitizeSmdLinks === 'function') _sanitizeSmdLinks(body);
if(typeof enhanceMarkdownTables === 'function') enhanceMarkdownTables(body);
}
if(typeof _smdMediaTailClear === 'function') _smdMediaTailClear(st.parser);
if(typeof _smdClearParserIdentity === 'function') _smdClearParserIdentity(body, st.parser);
st.finalized = true;
}
function _anchorProseIncrementalNode(key, text, options){
if(!window.smd || !key || typeof _safeSmdRenderer!=='function') return null;
const finalize=!!(options&&options.finalize);
const value=String(text||'');
const fade=typeof _shouldUseLiveProseFade==='function'&&_shouldUseLiveProseFade();
let st;
let _rewindPrevRendered='';
try{
st=_anchorProseSmdCache.get(key);
// Self-heal desyncs (edit/sanitize made the text no longer a pure append):
// rebuild the parser+node from scratch, mirroring the _smdWrite guard.
// Fade-flash guard: when the text REWINDS (tool-call XML stripped from the
// live prose), the rebuilt node would re-create every word as a new
// is-new span and replay the fade on ALL visible words at once. Mute the
// fade renderer for the common prefix so only the post-rewind tail fades.
if(st && st.writtenText && !value.startsWith(st.writtenText)){
// Snapshot the OLD rendered text BEFORE clearing the node. The silent
// prefix is later recomputed in RENDERED-text space (old node text vs
// new node text) — source-space byte counts are wrong here because
// markdown delimiters, link destinations and MEDIA tokens never reach
// the fade add_text hook, so a source-space budget over-mutes the
// first genuinely new word after a rewind (#6783 review).
const oldBody=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body');
_rewindPrevRendered=oldBody?(oldBody.textContent||''):'';
st=null;
}
if(st && st.fade!==fade) st=null;
if(st && st.finalized && st.writtenText!==value){
const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body');
if(typeof _smdMediaTailClear === 'function') _smdMediaTailClear(st.parser);
if(typeof _smdClearParserIdentity === 'function') _smdClearParserIdentity(body, st.parser);
_anchorProseSmdCache.delete(key);
st=null;
}
if(!st){
const node=document.createElement('div');
node.className='assistant-segment';
node.setAttribute('data-anchor-scene-prose','1');
const body=document.createElement('div');
body.className='msg-body';
if(body.classList) body.classList.toggle('stream-fade-active',fade);
node.appendChild(body);
const baseRenderer=fade?_streamFadeRenderer(body):_safeSmdRenderer(body);
const renderer=_smdRendererWithoutUnderscoreEmphasis(baseRenderer);
st={node,parser:window.smd.parser(renderer),writtenText:'',fade};
_smdBindParserIdentity(renderer,st.parser,body);
_anchorProseSmdCache.set(key,st);
// Bound memory across turns: keys embed the stream id, so stale entries
// from finished streams age out here.
if(_anchorProseSmdCache.size>32){
const oldest=_anchorProseSmdCache.keys().next().value;
if(oldest!==key) _anchorProseSmdCache.delete(oldest);
}
}
const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body');
if(body&&body.classList) body.classList.toggle('stream-fade-active',fade);
const delta=value.slice(st.writtenText.length);
if(delta){
window.smd.parser_write(st.parser,delta);
st.writtenText=value;
}
// Rewind rebuild: mute the rendered common prefix (old node text vs new
// node text) so already-visible words do not replay their fade; only the
// post-rewind tail animates. Rendered-space compare, not source-space
// (#6783 review — markdown/MEDIA bytes never reach add_text).
if(_rewindPrevRendered && typeof _streamFadeMuteRenderedPrefix==='function'){
_streamFadeMuteRenderedPrefix(body,_rewindPrevRendered);
_rewindPrevRendered='';
}
if(finalize){
_finalizeAnchorProseIncrementalNode(st);
}
st.node.dataset.rawText=value;
return st.node;
}catch(_){
if(st){
const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body');
if(typeof _smdMediaTailClear === 'function') _smdMediaTailClear(st.parser);
if(typeof _smdClearParserIdentity === 'function') _smdClearParserIdentity(body, st.parser);
}
_anchorProseSmdCache.delete(key);
return null;
}
}
window.__anchorProseIncrementalNode=_anchorProseIncrementalNode;
function _clearAnchorProseIncrementalNode(){
if(typeof window!=='undefined'&&window.__anchorProseIncrementalNode===_anchorProseIncrementalNode) window.__anchorProseIncrementalNode=null;
// Clear the per-parser MEDIA tail for each cached smd parser.
// _anchorProseSmdCache is a Map<key, {parser, ...}>; we can't
// iterate a WeakMap to clean up, but WeakMap keys become eligible
// for GC once the parser objects are released by the cache clear
// below, so the WeakMap entries are automatically removed. The
// explicit _smdMediaTailClear per-parser is a best-effort guard
// for cache entries that may hold the last strong reference.
if(typeof _anchorProseSmdCache!=='undefined'&&_anchorProseSmdCache.size){
_anchorProseSmdCache.forEach(function(st){
if(st&&st.parser&&typeof _smdMediaTailFlush==='function'){
_smdMediaTailFlush(st.parser);
}
if(st&&st.parser&&typeof _smdMediaTailClear==='function'){
_smdMediaTailClear(st.parser);
}
});
}
_anchorProseSmdCache.clear();
}
function _anchorHasReasoningEvents(){
const events=_anchorActivityEvents();
return !!(events&&events.some(event=>event&&event.source_event_type==='reasoning'));
}
function _upsertAnchorReasoning(text, options={}){
const clean=String(text||'').trim();
const placement=_liveThinkingPlacement();
const segmentSeq=Number(options.segmentSeq||placement.segmentSeq||_anchorSegmentSeq());
const localId=String(options.localId||`live-reasoning:${streamId}:${segmentSeq}`);
if(options&&typeof options==='object'){
options.anchorReasoningLocalId=localId;
options.segmentSeq=segmentSeq;
if(options.burstId===undefined) options.burstId=_currentActivityBurstId;
}
if(!clean||!_anchorRegistry||window._showThinking===false) return null;
const existing=_findAnchorActivityEventByLocalId(localId,'reasoning');
if(existing){
const replaced=_replaceAnchorActivityEventByLocalId(localId,'reasoning',{
status:options.sealed?'completed':'running',
payload:{text:clean,activitySegmentSeq:segmentSeq,activityBurstId:_currentActivityBurstId},
});
return _renderAnchorLiveScene()?replaced:null;
}
const renderOutcome={rendered:false};
_applyToAnchor('reasoning',{
text:clean,
local_id:localId,
seq:_nextAnchorLocalSeq(),
status:options.sealed?'completed':'running',
activitySegmentSeq:segmentSeq,
activityBurstId:_currentActivityBurstId,
},null,renderOutcome);
return renderOutcome.rendered?_findAnchorActivityEventByLocalId(localId,'reasoning'):null;
}
function _compactVisibleEchoText(value){
return String(value||'').replace(/\s+/g,'');
}
function _stripCompactEchoSuffix(value, suffix){
const raw=String(value||'');
const candidate=_compactVisibleEchoText(suffix);
if(!raw||!candidate) return {text:raw,removed:false};
const windowSize=Math.max(String(suffix||'').length*3,4096);
const offset=Math.max(0,raw.length-windowSize);
const tail=raw.slice(offset);
for(let idx=0;idx<=tail.length;idx+=1){
if(_compactVisibleEchoText(tail.slice(idx))===candidate){
return {text:raw.slice(0,offset+idx).trimEnd(),removed:true};
}
}
return {text:raw,removed:false};
}
function _stripAnchorReasoningEcho(visible){
const events=_anchorActivityEvents();
if(!events||!visible) return false;
for(let i=events.length-1;i>=0;i-=1){
const event=events[i];
if(!event||event.source_event_type!=='reasoning') continue;
const payload=(event.payload&&typeof event.payload==='object')?event.payload:{};
const rawText=String(payload.text||payload.reasoning||payload.thinking||'');
const stripped=_stripCompactEchoSuffix(rawText, visible);
if(!stripped.removed) continue;
const nextText=String(stripped.text||'').trim();
if(nextText){
_replaceAnchorActivityEventByLocalId(event.local_id,'reasoning',{
payload:{text:nextText},
});
}else{
events.splice(i,1);
}
_renderAnchorLiveScene();
return true;
}
return false;
}
function _removeLiveReasoningEchoRows(visible){
const turn=$('liveAssistantTurn');
const blocks=turn&&typeof _assistantTurnBlocks==='function'?_assistantTurnBlocks(turn):null;
if(!blocks||!visible) return false;
let removed=false;
const selector=[
'.agent-activity-thinking[data-anchor-scene-row="1"]',
'.agent-activity-thinking[data-live-thinking="1"]',
'.wl-reason[data-worklog-anchor-reason="1"]',
'.wl-reason[data-worklog-reason-source="reasoning"]'
].join(',');
blocks.querySelectorAll(selector).forEach(row=>{
const textNode=row.querySelector&&(
row.querySelector('.thinking-card-body pre') ||
row.querySelector('.thinking-card-body')
);
const text=String((textNode&&textNode.textContent)||row.textContent||'');
if(!_stripCompactEchoSuffix(text, visible).removed) return;
row.remove();
removed=true;
});
if(removed&&typeof _syncToolCallGroupSummary==='function'){
blocks.querySelectorAll('.tool-worklog-group,.tool-call-group').forEach(group=>{
_syncToolCallGroupSummary(group);
});
}
return removed;
}
function _stripLiveReasoningEcho(visible){
let removed=false;
const durable=_stripCompactEchoSuffix(reasoningText, visible);
if(durable.removed){
reasoningText=durable.text;
removed=true;
}
const live=_stripCompactEchoSuffix(liveReasoningText, visible);
if(live.removed){
liveReasoningText=live.text;
removed=true;
}
const anchorRemoved=_stripAnchorReasoningEcho(visible);
const domRemoved=_removeLiveReasoningEchoRows(visible);
if(removed) syncInflightAssistantMessage();
if((removed||anchorRemoved||domRemoved)&&!String(liveReasoningText||'').trim()&&typeof removeThinking==='function'){
removeThinking();
}
return removed||anchorRemoved||domRemoved;
}
function _flushReasoningToAnchor(){
if(_anchorReasoningFlushed||!reasoningText) return;
_anchorReasoningFlushed=true;
if(_anchorHasReasoningEvents()) return;
_upsertAnchorReasoning(reasoningText,{sealed:true,localId:`live-reasoning:${streamId}:final`});
}
function _sourceEventTypeForSnapshotAnchorRow(row){
const source=String(row&&row.source_event_type||'').trim();
if(source&&source!=='runtime_journal_snapshot') return source;
const role=String(row&&row.role||'').trim();
const kind=String(row&&row.kind||'').trim();
if(role==='prose'||kind==='process_prose') return 'token';
if(role==='thinking'||kind==='reasoning') return 'reasoning';
if(role==='tool') return row&&row.status==='running'?'tool':'tool_complete';
// Terminal statuses are done/cancel/error/apperror — never invent a
// compression start from a running terminal row (false "Compressing context").
if(role==='terminal'||kind==='terminal_status'){
const termStatus=String(row&&row.status||'').trim().toLowerCase();
if(termStatus==='cancelled'||termStatus==='canceled'||termStatus==='interrupted') return 'cancel';
if(termStatus==='error'||termStatus==='failed'||termStatus==='errored') return 'error';
if(termStatus==='running') return '';
return 'done';
}
// lifecycle_status is shared by compressing + compressed. Prefer explicit
// cues; do not default every lifecycle row to a running compress divider.
if(role==='lifecycle'||kind==='lifecycle_status'){
const phase=String(row&&(row.phase||row.status)||'').trim().toLowerCase();
const text=String(row&&(row.text||row.message||row.label)||'').trim().toLowerCase();
if(
phase==='done'||phase==='completed'||phase==='compressed'
|| text.includes('auto-compressed')
|| text.includes('compression finished')
|| (text.includes('compressed')&&!text.includes('compressing'))
) return 'compressed';
if(
// NOT a bare phase==='running'. routes.py appends a placeholder
// "live anchor shell" row (role lifecycle, status running,
// source_event_type runtime_journal_snapshot) whenever a stream has
// events but no visible rows yet. That falls through the source check
// above, and a bare running phase then classified every such shell as
// a compression start - a permanent phantom "Compressing context"
// divider on sessions that never compressed anything.
phase==='compressing'
|| text.includes('compressing context')
|| text.includes('compacting context')
|| text.includes('preflight compression')
|| text.includes('pre-api compression')
|| text.includes('context too large')
|| text.includes('compression attempt')
|| (text.includes('compressing')&&!text.includes('skipping'))
) return 'compressing';
return '';
}
return '';
}
function _hydrateAnchorRegistryFromActivityScene(scene){
if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.applyAssistantTurnAnchorSourceEvent!=='function') return false;
if(!scene||scene.version!=='activity_scene_v1'||!Array.isArray(scene.activity_rows)||!scene.activity_rows.length) return false;
const sceneIdentity=(scene.identity&&typeof scene.identity==='object')?scene.identity:{};
const sceneStreamId=sceneIdentity.stream_id||streamId;
const sceneRunId=sceneIdentity.run_id||sceneStreamId;
const sceneKey=[
sceneRunId||'',
sceneStreamId||'',
scene.activity_rows.length,
scene.activity_rows.map(row=>row&&row.row_id||row&&row.local_id||'').join('|'),
].join(':');
if(_anchorRegistry._hydrated_activity_scene_key===sceneKey) return true;
const rows=scene.activity_rows;
for(let i=0;i<rows.length;i+=1){
const row=rows[i];
if(!row||typeof row!=='object') continue;
const sourceType=_sourceEventTypeForSnapshotAnchorRow(row);
if(!sourceType) continue;
const payload={
...((row.payload&&typeof row.payload==='object')?row.payload:{}),
};
if(row.text&&!payload.text) payload.text=row.text;
if(row.tool&&typeof row.tool==='object'){
payload.name=payload.name||row.tool.name;
payload.args=payload.args||row.tool.args;
payload.preview=payload.preview||row.tool.preview;
payload.snippet=payload.snippet||row.tool.snippet;
payload.tid=payload.tid||row.tool.tid||row.tool.id;
payload.id=payload.id||row.tool.id||row.tool.tid;
payload.is_error=payload.is_error||row.tool.is_error;
payload.duration=payload.duration||row.tool.duration;
}
if(row.group&&typeof row.group==='object'){
payload.activitySegmentSeq=payload.activitySegmentSeq||row.group.activity_segment_seq;
payload.activityBurstId=payload.activityBurstId||row.group.activity_burst_id;
}
const rowIdentity=(row.identity&&typeof row.identity==='object')?row.identity:{};
const sourceEvent={
...payload,
source_event_type:sourceType,
local_id:row.local_id||row.row_id||`snapshot:${sceneStreamId}:${i}`,
event_id:row.event_id||null,
seq:row.seq??undefined,
status:row.status||undefined,
stream_id:row.stream_id||rowIdentity.stream_id||sceneStreamId,
run_id:row.run_id||rowIdentity.run_id||sceneRunId,
// Carry the row's persisted creation timestamp through hydration so the
// worklog event timestamp (#5700/#5739) survives a settled-snapshot rebuild
// (payload may not carry created_at even when the row does). (#5739 gate.)
created_at:payload.created_at??row.created_at??undefined,
};
try{
_anchorApi.applyAssistantTurnAnchorSourceEvent(_anchorRegistry,sourceEvent,{session_id:activeSid,stream_id:sceneStreamId,run_id:sceneRunId});
}catch(err){
if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){
_anchorShadowWarned=true;
console.warn('assistant turn anchor snapshot hydration failed',err);
}
return false;
}
}
_anchorRegistry._hydrated_activity_scene_key=sceneKey;
return true;
}
_hydrateAnchorRegistryFromActivityScene(INFLIGHT[activeSid]&&INFLIGHT[activeSid].anchorActivityScene);
function _mergeSettledToolCallsWithLiveMetadata(rawCalls){
const liveCalls=Array.isArray(S.toolCalls)?S.toolCalls:[];
const byTid=new Map();
liveCalls.forEach((tc,idx)=>{
if(!tc||typeof tc!=='object') return;
const tid=tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'';
if(tid&&!byTid.has(tid)) byTid.set(tid,{tc,idx});
});
const used=new Set();
return (rawCalls||[]).map((raw,idx)=>{
const next={...(raw||{}),done:true};
const tid=next.tid||next.id||next.tool_call_id||next.tool_use_id||next.call_id||'';
let matchEntry=tid?byTid.get(tid):null;
if(!matchEntry){
const name=next.name||((next.function||{}).name)||'';
const matchIdx=liveCalls.findIndex((tc,i)=>tc&&!used.has(i)&&(!name||tc.name===name));
if(matchIdx>=0) matchEntry={tc:liveCalls[matchIdx],idx:matchIdx};
}
if(matchEntry){
used.add(matchEntry.idx);
const live=matchEntry.tc||{};
for(const key of ['activityBurstId','duration','started_at']){
if((next[key]===undefined||next[key]===null)&&live[key]!==undefined&&live[key]!==null) next[key]=live[key];
}
}
return next;
});
}
// rAF-throttled rendering: buffer tokens, render at most once per frame
let _renderPending=false;
// Extract display text from assistantText, stripping completed thinking blocks
// and hiding content still inside an open thinking block.
function _stripXmlToolCalls(s){
// Strip <function_calls>...</function_calls> blocks (DeepSeek XML tool syntax).
// These are processed as tool calls server-side; showing them raw in the bubble
// looks broken. Also handles orphaned opening tags mid-stream. (#702)
// Also handles DSML-prefixed variants from DeepSeek/Bedrock, including
// spacing variants like "<|DSML |function_calls" and truncated prefixes.
if(!s) return s;
// Case-insensitive presence check without allocating a full lowercased copy
// of the (growing) text on every call — cuts per-token/per-frame GC pressure.
// Equivalent to the previous toLowerCase()+indexOf gate. (#5455 WS2.3)
if(!/function_calls|dsml/i.test(String(s))) return s;
// Support both plain <function_calls> 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.replace(/^\s+/, '');
}
function _streamDisplay(){
return _extractInlineThinkingFromContent(_stripXmlToolCalls(assistantText), liveReasoningText, {streaming:true}).content;
}
function _parseStreamState(){
return _extractInlineThinkingFromContent(_stripXmlToolCalls(assistantText), liveReasoningText, {streaming:true});
}
function _renderLiveThinking(parsed){
if(window._showThinking===false){removeThinking();return;}
const text=(parsed&&parsed.thinkingText)||'';
if(text||(parsed&&parsed.inThinking)){
_updateLiveThinkingCard(text||'Thinking…');
return;
}
// Only remove thinking if we're not in an active reasoning phase.
// When reasoningText is set but liveReasoningText was just reset (post-tool),
// don't wipe the finalized thinking card — it has no id anymore so
// removeThinking() won't find it anyway, but guard explicitly.
if(!reasoningText) removeThinking();
}
// Helper: create (or recreate) the smd parser bound to a given DOM element.
// Called when assistantBody is first created and after each tool-call segment reset.
function _smdNewParser(el, fade=false){
_smdWrittenLen=0;
_smdWrittenText='';
if(!window.smd){_smdParser=null;return;}
const baseRenderer=fade ? _streamFadeRenderer(el) : _safeSmdRenderer(el);
const renderer=_smdRendererWithoutUnderscoreEmphasis(baseRenderer);
_smdParser=window.smd.parser(renderer);
_smdBindParserIdentity(renderer,_smdParser,el);
}
function _smdRendererWithoutUnderscoreEmphasis(renderer){
if(!renderer||!window.smd) return renderer;
const baseAddToken=renderer.add_token;
const baseEndToken=renderer.end_token;
const baseAddText=renderer.add_text;
const tokenStack=[];
renderer.add_token=(data,token)=>{
if(token===window.smd.ITALIC_UND||token===window.smd.STRONG_UND){
const marker=token===window.smd.STRONG_UND?'__':'_';
tokenStack.push(marker);
baseAddText(data,marker);
return;
}
tokenStack.push(null);
baseAddToken(data,token);
};
renderer.end_token=(data)=>{
const marker=tokenStack.pop();
if(marker){
baseAddText(data,marker);
return;
}
baseEndToken(data);
};
return renderer;
}
// Helper: end the current smd parser (flushes remaining state) and null it out.
function _smdEndParser(){
if(_streamingKatexTimer){clearTimeout(_streamingKatexTimer);_streamingKatexTimer=null;}
if(_smdParser&&window.smd){
try{window.smd.parser_end(_smdParser);}catch(_){}
}
// parser_end may emit one final add_text chunk; flush MEDIA tails after it
// so a final extensionless URL is rendered before the settled re-render.
if(typeof _smdMediaTailFlush==='function') _smdMediaTailFlush(_smdParser);
if(typeof _smdMediaTailFlush==='function') _smdMediaTailFlush(__SMD_PARSER_FALLBACK);
// parser_end / tail flush may create new links/images — re-sanitize the
// body before the DOM is handed off to highlightCode / renderMessages.
if(assistantBody){_sanitizeSmdLinks(assistantBody);enhanceMarkdownTables(assistantBody);}
// Clear the per-parser MEDIA tail buffer — any incomplete MEDIA
// prefix the parser was holding is no longer relevant.
if(typeof _smdMediaTailClear==='function') _smdMediaTailClear(_smdParser);
if(typeof _smdClearParserIdentity==='function') _smdClearParserIdentity(assistantBody,_smdParser);
_smdParser=null;
_smdWrittenLen=0;
_smdWrittenText='';
// Clear the fallback MEDIA tail buffer too; fallback chunks are keyed
// by __SMD_PARSER_FALLBACK, not null.
if(typeof _smdMediaTailClear==='function') _smdMediaTailClear(__SMD_PARSER_FALLBACK);
}
function _scheduleStreamingKatex(){
if(_streamingKatexTimer) return;
_streamingKatexTimer=setTimeout(()=>{
_streamingKatexTimer=null;
if(assistantBody&&typeof renderKatexBlocks==='function') renderKatexBlocks(assistantBody,{streaming:true});
},150);
}
// Helper: feed new displayText delta to the smd parser.
// Only feeds chars beyond what has already been written (_smdWrittenLen).
function _smdWrite(displayText, fade=false){
if(!_smdParser||!window.smd) return;
displayText=String(displayText||'');
let _rewindPrevRendered='';
// Self-heal desyncs: if displayText no longer starts with what we have
// already written (e.g. due to stream sanitization/tag stripping), incremental slicing
// can skip characters. Rebuild parser from the full current displayText.
if(_smdWrittenText && !displayText.startsWith(_smdWrittenText)){
// Fade-flash fix: when the visible text REWINDS (tool-call XML stripping
// makes displayText a strict prefix of what was already written), the
// rebuild below would clear the body and re-create every word as a new
// `is-new` span — replaying the fade animation on ALL visible text at
// once (a full-message blink on every tool call). Instead, snapshot the
// OLD RENDERED text and mute the rebuild prefix spans AFTER the
// parser_write, in RENDERED-text space: source-space byte counts include
// markdown delimiters / link destinations / MEDIA token bytes that never
// reach the fade add_text hook, so a source-space budget over-mutes the
// first genuinely new word after a rewind (#6783 review).
if(assistantBody && typeof assistantBody.textContent==='string'){
_rewindPrevRendered=assistantBody.textContent;
}
_smdParser=null;
_smdWrittenLen=0;
_smdWrittenText='';
if(assistantBody) assistantBody.innerHTML='';
_smdNewParser(assistantBody,fade);
if(!_smdParser) return;
}
const delta=displayText.slice(_smdWrittenText.length);
if(!delta) return;
try{window.smd.parser_write(_smdParser,delta);}catch(_){}
_smdWrittenLen=displayText.length;
_smdWrittenText=displayText;
// Rebuild after a rewind: strip is-new from spans covered by the
// RENDERED common prefix (old node text vs new node text), so already-
// visible words stay plain while only the post-rewind tail fades.
if(_rewindPrevRendered && typeof _streamFadeMuteRenderedPrefix==='function'){
_streamFadeMuteRenderedPrefix(assistantBody,_rewindPrevRendered);
}
// URL scheme safety is handled by the renderer's set_attr hook
// (_safeSmdRenderer or _streamFadeRenderer), applied inline as smd
// creates each DOM node — no post-hoc full-DOM scan needed.
_scheduleStreamingKatex();
}
// Allowed URL schemes for anchors and images rendered from agent-streamed markdown.
// Raw file:// anchors are rewritten to /api/media before the user can click them.
const _SMD_SAFE_URL_RE=/^(?:https?:|mailto:|tel:|message:|\/|#|\?|\.|api|session\/)/i;
// ui.js owns the image-only data URI policy. It loads before this script;
// fail closed if that contract is unavailable rather than inventing a second
// allowlist that can drift from settled rendering.
const _SMD_SAFE_IMG_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.)/i;
function _smdImgSrcAllowed(v){
const s=String(v||'');
if(/^data:/i.test(s)) return typeof _isSafeDataImageUri==='function'&&_isSafeDataImageUri(s);
return _SMD_SAFE_IMG_URL_RE.test(s);
}
function _smdLinkHref(raw){
const href=String(raw||'');
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)) return 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';
}
}
function _smdFileHref(raw){
return _smdLinkHref(raw);
}
function _sanitizeSmdLinks(root){
if(!root||!root.querySelectorAll) return;
const _a=root.querySelectorAll('a[href]');
for(let i=0;i<_a.length;i++){
const n=_a[i],v=n.getAttribute('href')||'';
if(/^(file|workspace|session):\/\//i.test(v)){n.setAttribute('href',_smdLinkHref(v));n.classList&&/^session:\/\//i.test(v)&&n.classList.add('session-link');continue;}
if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('href');n.setAttribute('data-blocked-scheme','1');}
}
const _im=root.querySelectorAll('img[src]');
for(let i=0;i<_im.length;i++){
const n=_im[i],v=n.getAttribute('src')||'';
if(!_smdImgSrcAllowed(v)){n.removeAttribute('src');n.setAttribute('data-blocked-scheme','1');}
}
}
function _resetStreamFadeState(){
_streamFadeVisibleText='';
_streamFadeLastTickMs=0;
_streamFadeWordCarry=0;
_streamFadeStartedAt=0;
_streamFadeLastTargetWords=0;
_streamFadeLastArrivalMs=0;
_streamFadeArrivalWps=0;
_streamFadeLatestAnimationEndAt=0;
_streamFadeVisibleWords=0;
_streamFadeHoldUntilMs=0;
_streamFadeCurrentMs=_STREAM_FADE_MS;
_streamFadeDomText='';
_streamFadeSilentPrefixChars=0;
}
function _cancelAnimationFramePendingStreamRender(){
if(_pendingRafHandle===null) return;
cancelAnimationFrame(_pendingRafHandle);
clearTimeout(_pendingRafHandle);
_pendingRafHandle=null;
_renderPending=false;
}
function _shouldUseStreamFade(){
return window._fadeTextEffect===true;
}
function _shouldUseTransparentStreamFade(){
return typeof isTransparentStream==='function'&&isTransparentStream();
}
function _shouldUseLiveProseFade(){
return !_streamFadeReduceMotionEnabled() && (_shouldUseStreamFade() || _shouldUseTransparentStreamFade());
}
function _streamFadeSkipNode(node){
if(!node||node.nodeType!==1) return false;
const tag=(node.tagName||'').toLowerCase();
return tag==='pre'||tag==='code'||tag==='script'||tag==='style'||tag==='textarea'||tag==='svg'||tag==='math';
}
function _streamFadeReduceMotionEnabled(){
if(!window.matchMedia) return false;
if(!_streamFadeReduceMotionMql){
_streamFadeReduceMotionMql=window.matchMedia('(prefers-reduced-motion: reduce)');
_streamFadeReduceMotion=!!_streamFadeReduceMotionMql.matches;
_streamFadeReduceMotionOnChange=e=>{_streamFadeReduceMotion=!!e.matches;};
try{_streamFadeReduceMotionMql.addEventListener('change',_streamFadeReduceMotionOnChange);}
catch(_){try{_streamFadeReduceMotionMql.addListener(_streamFadeReduceMotionOnChange);}catch(_){}}
}
return _streamFadeReduceMotion;
}
function _streamFadeCleanupReduceMotionListener(){
if(!_streamFadeReduceMotionMql||!_streamFadeReduceMotionOnChange) return;
try{_streamFadeReduceMotionMql.removeEventListener('change',_streamFadeReduceMotionOnChange);}
catch(_){try{_streamFadeReduceMotionMql.removeListener(_streamFadeReduceMotionOnChange);}catch(_){}}
_streamFadeReduceMotionMql=null;
_streamFadeReduceMotionOnChange=null;
}
function _streamFadeBindCleanup(el){
if(!el||el._streamFadeCleanupBound) return;
el._streamFadeCleanupBound=true;
el.addEventListener('animationend',e=>{
const span=e.target;
if(!span||!span.classList||!span.classList.contains('stream-fade-word')) return;
// Keep the animated inline node stable for the lifetime of the live turn.
// Replacing each word with a fresh text node makes native scroll anchoring
// choose a new anchor while the transcript is still growing, producing a
// visible vertical bounce. Final settlement rebuilds plain persisted DOM.
span.classList.remove('is-new');
if(span.style) span.style.removeProperty('--stream-fade-ms');
});
}
function _streamFadeRenderer(el){
_streamFadeBindCleanup(el);
const renderer=window.smd.default_renderer(el);
const baseAddText=renderer.add_text;
const baseSetAttr=renderer.set_attr;
const parserFor = (data)=>{
return _smdParserKey(data, el);
};
const writeFadeText=(writeParent, writeData, writeText)=>{
if(!writeParent||_streamFadeSkipNode(writeParent)){
_smdAppendPlainText(writeParent, writeData, writeText, baseAddText);
return;
}
_streamFadeAppendText(writeParent, writeText);
};
renderer.add_text=(data,text)=>{
const parent=data&&data.nodes&&data.nodes[data.index];
if(!parent||_streamFadeSkipNode(parent)){baseAddText(data,text);return;}
// MEDIA-in-stream: if this chunk carries a MEDIA:<ref> token, defer to
// the shared interceptor so the token becomes a real media element
// instead of plain text. The fade renderer would otherwise wrap every
// word in a stream-fade-word span, leaving MEDIA: paths visible.
const parser=parserFor(data);
const hasMediaTail=!!(_SMD_MEDIA_TAIL&&parser&&_SMD_MEDIA_TAIL.has&&_SMD_MEDIA_TAIL.has(parser));
const value=String(text||'');
const hasMediaPrefixTail=!!_smdMediaPrefixTail(value);
if(/MEDIA:/.test(value)||hasMediaTail||hasMediaPrefixTail){
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser, writeFadeText);
return;
}
const frag=document.createDocumentFragment();
const wordRe=/(\S+)(\s*)/g;
const reduceMotion=_streamFadeReduceMotionEnabled();
const appendStartedAt=performance.now();
let last=0, match, changed=false;
// Silent-prefix window: after a rebuild caused by a REWIND (tool-call
// XML stripping), words that were already visible before the rewind
// point must NOT replay their fade animation. They are appended as
// plain text; only the tail beyond _streamFadeSilentPrefixChars fades.
let silentLeft=_streamFadeSilentPrefixChars||0;
while((match=wordRe.exec(value))){
if(match.index>last) frag.appendChild(document.createTextNode(value.slice(last,match.index)));
if(reduceMotion){
frag.appendChild(document.createTextNode(match[1]));
if(match[2]) frag.appendChild(document.createTextNode(match[2]));
last=match.index+match[0].length;
changed=true;
continue;
}
if(silentLeft>0){
frag.appendChild(document.createTextNode(match[1]));
if(match[2]) frag.appendChild(document.createTextNode(match[2]));
silentLeft-=match[0].length;
last=match.index+match[0].length;
changed=true;
continue;
}
const span=document.createElement('span');
span.className='stream-fade-word is-new';
const fadeMs=_streamFadeCurrentMs||_STREAM_FADE_MS;
if(fadeMs!==_STREAM_FADE_MS) span.style.setProperty('--stream-fade-ms',fadeMs+'ms');
span.textContent=match[1];
frag.appendChild(span);
_streamFadeLatestAnimationEndAt=Math.max(_streamFadeLatestAnimationEndAt,appendStartedAt+fadeMs);
if(match[2]) frag.appendChild(document.createTextNode(match[2]));
last=match.index+match[0].length;
changed=true;
}
if(silentLeft>0) _streamFadeSilentPrefixChars=silentLeft;
else _streamFadeSilentPrefixChars=0;
if(!changed){baseAddText(data,text);return;}
if(last<value.length) frag.appendChild(document.createTextNode(value.slice(last)));
parent.appendChild(frag);
};
renderer.set_attr=(data,attr,value)=>{
const isHref=window.smd&&attr===window.smd.HREF;
const isSrc=window.smd&&attr===window.smd.SRC;
const allowed=isSrc?_smdImgSrcAllowed(value):_SMD_SAFE_URL_RE.test(String(value||''));
if(isHref&&/^(file|workspace|session):\/\//i.test(String(value||''))){
baseSetAttr(data,attr,_smdLinkHref(value));
if(/^session:\/\//i.test(String(value||''))){
const node=data&&data.nodes&&data.nodes[data.index];
if(node&&node.classList) node.classList.add('session-link');
}
return;
}
if((isHref||isSrc)&&!allowed){
const node=data&&data.nodes&&data.nodes[data.index];
if(node&&node.setAttribute) node.setAttribute('data-blocked-scheme','1');
return;
}
baseSetAttr(data,attr,value);
};
return renderer;
}
// Safe renderer: wraps default_renderer with a set_attr hook that validates
// href/src URL schemes inline — no post-hoc DOM-wide querySelectorAll needed.
// Unlike _streamFadeRenderer, this does NOT wrap add_text, so smd adds new
// DOM nodes as plain text nodes (no animation spans). Used on the non-fade
// streaming path to eliminate _sanitizeSmdLinks(assistantBody) O(DOM) scans
// on every token event (#WebUI-perf).
// MEDIA-in-stream fix: also wraps add_text so MEDIA:<ref> tokens that arrive
// mid-turn are converted to inline media elements at insert time, matching
// what the full renderMd() pipeline does on the settled assistant message.
// Without this, streamed prose shows MEDIA:C:\... as literal text until the
// turn settles and the full re-render swaps it for the real <img>.
// SAFETY & CROSS-CHUNK SPLITS (Greptile #1 + #2):
// 1. Prose slices go back to the owning text writer (text nodes or
// fade spans), NOT through DOMParser — mixed prose with HTML entities /
// malicious <img onerror> stays as literal text.
// 2. Each MEDIA token's HTML (from _inlineMediaHtmlForRef) is handed
// to DOMParser one at a time — only trusted markup is parsed.
// 3. A MEDIA prefix split across smd flushes (e.g. "MEDIA:" then
// "foo.png") is buffered in a per-parser tail buffer and completed
// on the next add_text call.
const _MEDIA_TAIL_MAX = 4096; // bytes; defensive cap on per-parser buffer
const _SMD_MEDIA_PREFIX = 'MEDIA:';
function _smdMediaPrefixTail(value){
const text=String(value||'');
const max=Math.min(_SMD_MEDIA_PREFIX.length,text.length);
for(let len=max;len>0;len-=1){
const suffix=text.slice(text.length-len);
if(_SMD_MEDIA_PREFIX.startsWith(suffix)) return suffix;
}
return '';
}
function _smdAppendPlainText(parent, data, text, baseAddText){
const value=String(text||'');
if(parent&&parent.appendChild&&typeof document!=='undefined'&&document.createTextNode){
parent.appendChild(document.createTextNode(value));
return;
}
if(baseAddText) baseAddText(data,value);
}
function _smdMediaWriteText(parent, data, baseAddText, writeText, text){
if(writeText){
writeText(parent, data, String(text||''));
return;
}
if(baseAddText) baseAddText(data,String(text||''));
}
function _smdMediaTailSet(tailMap, parser, chunk, parent, baseAddText, data, writeText){
if(!tailMap||!parser) return;
if(chunk) tailMap.set(parser, {chunk, parent, baseAddText, data, writeText});
else tailMap.delete(parser);
}
function _smdMediaTailEntryChunk(entry){
return entry && typeof entry==='object' && Object.prototype.hasOwnProperty.call(entry,'chunk') ? entry.chunk : entry;
}
function _smdMediaTailSameOwner(entry, parent, baseAddText, writeText){
return !!entry && entry.parent===parent && entry.baseAddText===baseAddText && entry.writeText===writeText;
}
function _smdMediaRefHasReliableBoundary(rawRef){
const raw=String(rawRef||'');
if(/[?#]$/.test(raw)) return false;
const ref=raw.split(/[?#]/,1)[0];
return /\.(?:png|jpe?g|gif|webp|bmp|ico|svg|avif|mp4|webm|mov|m4v|mkv|avi|ogv|mp3|wav|ogg|m4a|aac|wma|opus|flac|oga|pdf|html?|csv|diff|patch|excalidraw)$/i.test(ref);
}
function _smdMediaTailFlushEntry(entry){
const chunk=_smdMediaTailEntryChunk(entry);
if(!chunk) return;
const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk));
const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1]));
if(!emitted && entry) _smdMediaWriteText(entry.parent, entry.data, entry.baseAddText, entry.writeText, chunk);
}
function _smdMediaTailFlush(parser){
if(!_SMD_MEDIA_TAIL||!parser||!_SMD_MEDIA_TAIL.get) return;
const entry=_SMD_MEDIA_TAIL.get(parser);
if(!entry) return;
_SMD_MEDIA_TAIL.delete(parser);
_smdMediaTailFlushEntry(entry);
}
function _smdMediaAwareAddText(baseAddText, parent, data, text, tailMap, parser, writeText){
const value=String(text||'');
const tails=tailMap||(typeof _SMD_MEDIA_TAIL!=='undefined'&&_SMD_MEDIA_TAIL)||null;
const writeCurrent=(chunk)=>_smdMediaWriteText(parent, data, baseAddText, writeText, chunk);
if(!value){
writeCurrent('');
return;
}
// Pull any pending tail from a previous (split) chunk, then clear it;
// this call will either complete it, re-buffer it, or flush it as text.
let leadEntry = tails && parser && tails.get ? tails.get(parser) : null;
let lead = _smdMediaTailEntryChunk(leadEntry);
if(lead && !_smdMediaTailSameOwner(leadEntry, parent, baseAddText, writeText)){
if(tails && parser && tails.delete) tails.delete(parser);
_smdMediaTailFlushEntry(leadEntry);
leadEntry=null;
lead='';
}else if(lead && tails && parser && tails.delete){
tails.delete(parser);
}
const combined = lead ? lead + value : value;
// Fast path: no MEDIA tokens in the (possibly combined) string.
if(!/MEDIA:/.test(combined)){
const prefixTail=_smdMediaPrefixTail(combined);
if(prefixTail && tails && parser && prefixTail.length < _MEDIA_TAIL_MAX){
const stable=combined.slice(0, combined.length-prefixTail.length);
if(stable) writeCurrent(stable);
_smdMediaTailSet(tails, parser, prefixTail, parent, baseAddText, data, writeText);
return;
}
writeCurrent(combined);
return;
}
// Walk the combined string, slicing into prose + MEDIA token runs.
// Prose runs go through the owning text writer. MEDIA tokens go through
// the single-token DOMParser helper only after a delimiter or
// reliable filename suffix proves the ref is complete.
const re=/MEDIA:([^\s\)\]]+)/g;
let last=0, m;
let unmatchedTail=null;
while((m=re.exec(combined))){
const matchEnd = m.index + m[0].length;
if(m.index>last){
const slice = combined.slice(last, m.index);
writeCurrent(slice);
}
if(matchEnd===combined.length && !_smdMediaRefHasReliableBoundary(m[1])){
const candidate = combined.slice(m.index);
if(candidate.length < _MEDIA_TAIL_MAX){
unmatchedTail = candidate;
} else {
writeCurrent(candidate);
}
last = combined.length;
break;
}
if(!_smdAppendMediaNode(parent, m[1])) writeCurrent(m[0]);
last = matchEnd;
}
// Tail buffer — hold trailing bytes that look like an unterminated
// MEDIA prefix; flush any prose before the partial MEDIA suffix.
const rest = combined.slice(last);
if(rest){
const tailMatch = /MEDIA:[^\s\)\]]*$/.exec(rest);
const prefixTail = tailMatch ? '' : _smdMediaPrefixTail(rest);
const tailValue = tailMatch ? tailMatch[0] : prefixTail;
if(tailValue && rest.length < _MEDIA_TAIL_MAX){
const tailStart = tailMatch ? tailMatch.index : rest.length-prefixTail.length;
if(tailStart>0) writeCurrent(rest.slice(0, tailStart));
unmatchedTail = tailValue;
} else {
writeCurrent(rest);
}
}
if(tails && parser){
_smdMediaTailSet(tails, parser, unmatchedTail, parent, baseAddText, data, writeText);
}
}
// Single-token DOM splice. Only ever fed the output of
// _inlineMediaHtmlForRef (trusted markup fragment). Plain text
// goes through baseAddText → createTextNode — NEVER here.
function _smdAppendMediaNode(parent, rawRef){
if(!parent||!rawRef) return false;
const mediaHtml = (typeof _inlineMediaHtmlForRef==='function')
? _inlineMediaHtmlForRef(String(rawRef))
: '';
if(!mediaHtml) return false;
let host=null;
try{
const doc=new DOMParser().parseFromString('<div>'+mediaHtml+'</div>','text/html');
host=doc.body&&doc.body.firstChild;
}catch(_){ host=null; }
if(!host||!host.childNodes||!host.childNodes.length) return false;
const frag=document.createDocumentFragment();
while(host.firstChild) frag.appendChild(host.firstChild);
parent.appendChild(frag);
_smdScheduleMediaPostProcess(parent);
return true;
}
function _smdScheduleMediaPostProcess(root){
if(!root) return;
if(typeof _postProcessWithAnchorSuppression!=='function'
&& typeof postProcessRenderedMessages!=='function'
&& typeof _applyMediaPlaybackPreferences!=='function') return;
const run=()=>{
try{
if(typeof _postProcessWithAnchorSuppression==='function') _postProcessWithAnchorSuppression(root);
else if(typeof postProcessRenderedMessages==='function') postProcessRenderedMessages(root);
if(typeof _applyMediaPlaybackPreferences==='function') _applyMediaPlaybackPreferences(root);
}catch(_){}
};
if(typeof requestAnimationFrame==='function') requestAnimationFrame(run);
else if(typeof setTimeout==='function') setTimeout(run,0);
else run();
}
// Per-parser tail buffer keyed by parser instance so concurrent
// smd parsers (live prose + anchor-scene rows + tool-card streams)
// keep their own pending bytes. Cleared inside _smdEndParser /
// _clearAnchorProseIncrementalNode on stream end.
const _SMD_MEDIA_TAIL = (typeof WeakMap!=='undefined') ? new WeakMap() : new Map();
// Sentinel for parserFor fallback — a dedicated object instead of
// a string, so WeakMap.set doesn't throw TypeError when all three
// parser-identity sources are unavailable (Greptile #3).
const __SMD_PARSER_FALLBACK = {};
function _smdParserKey(data, el){
return (data && data.parser) || (el && el.__smdParser) || __SMD_PARSER_FALLBACK;
}
function _smdBindParserIdentity(renderer, parser, el){
if(renderer&&renderer.data) renderer.data.parser=parser;
if(el) el.__smdParser=parser;
}
function _smdClearParserIdentity(el, parser){
if(!el || (parser && el.__smdParser!==parser)) return;
try{delete el.__smdParser;}catch(_){el.__smdParser=null;}
}
function _smdMediaTailClear(parser){
if(_SMD_MEDIA_TAIL && parser) _SMD_MEDIA_TAIL.delete(parser);
// Also clear the fallback key if it was ever set
if(_SMD_MEDIA_TAIL && parser === __SMD_PARSER_FALLBACK) _SMD_MEDIA_TAIL.delete(parser);
}
function _safeSmdRenderer(el){
const renderer=window.smd.default_renderer(el);
const baseSetAttr=renderer.set_attr;
const baseAddText=renderer.add_text;
const writePlainText=(writeParent, writeData, writeText)=>{
_smdAppendPlainText(writeParent, writeData, writeText, baseAddText);
};
const parserFor = (data)=>{
return _smdParserKey(data, el);
};
renderer.add_text=(data,text)=>{
const parent=data&&data.nodes&&data.nodes[data.index];
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parserFor(data), writePlainText);
};
renderer.set_attr=(data,attr,value)=>{
const isHref=window.smd&&attr===window.smd.HREF;
const isSrc=window.smd&&attr===window.smd.SRC;
const allowed=isSrc?_smdImgSrcAllowed(value):_SMD_SAFE_URL_RE.test(String(value||''));
if(isHref&&/^(file|workspace|session):\/\//i.test(String(value||''))){
baseSetAttr(data,attr,_smdLinkHref(value));
if(/^session:\/\//i.test(String(value||''))){
const node=data&&data.nodes&&data.nodes[data.index];
if(node&&node.classList) node.classList.add('session-link');
}
return;
}
if((isHref||isSrc)&&!allowed){
const node=data&&data.nodes&&data.nodes[data.index];
if(node&&node.setAttribute) node.setAttribute('data-blocked-scheme','1');
return;
}
baseSetAttr(data,attr,value);
};
return renderer;
}
function _streamFadeWordCountOf(text){
const m=String(text||'').match(/\S+/g);
return m?m.length:0;
}
function _streamFadeAppendText(el, text){
if(!el) return;
const value=String(text||'');
if(!value) return;
const reduceMotion=_streamFadeReduceMotionEnabled();
const frag=document.createDocumentFragment();
const wordRe=/(\S+)(\s*)/g;
const appendStartedAt=performance.now();
let last=0, match, changed=false;
// Silent-prefix window (same contract as _streamFadeRenderer.add_text):
// after a rewind-triggered rebuild, words before the rewind point must
// not replay their fade animation.
let silentLeft=_streamFadeSilentPrefixChars||0;
while((match=wordRe.exec(value))){
if(match.index>last) frag.appendChild(document.createTextNode(value.slice(last,match.index)));
if(reduceMotion){
frag.appendChild(document.createTextNode(match[1]));
}else if(silentLeft>0){
frag.appendChild(document.createTextNode(match[1]));
silentLeft-=match[0].length;
}else{
const span=document.createElement('span');
span.className='stream-fade-word is-new';
const fadeMs=_streamFadeCurrentMs||_STREAM_FADE_MS;
if(fadeMs!==_STREAM_FADE_MS) span.style.setProperty('--stream-fade-ms',fadeMs+'ms');
span.textContent=match[1];
frag.appendChild(span);
_streamFadeLatestAnimationEndAt=Math.max(_streamFadeLatestAnimationEndAt,appendStartedAt+fadeMs);
}
if(match[2]) frag.appendChild(document.createTextNode(match[2]));
last=match.index+match[0].length;
changed=true;
}
if(silentLeft>0) _streamFadeSilentPrefixChars=silentLeft;
else _streamFadeSilentPrefixChars=0;
if(!changed){
frag.appendChild(document.createTextNode(value));
}else if(last<value.length){
frag.appendChild(document.createTextNode(value.slice(last)));
}
el.appendChild(frag);
}
// Rendered-text-space mute for rewind rebuilds (#6783 review): after a
// rewind-triggered rebuild every word is a fresh `is-new` span. Compare the
// OLD rendered text (snapshot before rebuild) with the NEW rendered text in
// RENDERED coordinates — what smd's add_text actually emits; markdown
// delimiters, link destinations and MEDIA token bytes never reach it — and
// strip `is-new` from spans inside the common prefix, so already-visible
// words don't replay their fade while the genuinely new tail still animates.
function _streamFadeMuteRenderedPrefix(rootEl, prevRendered){
if(!rootEl || !prevRendered) return;
const newRendered=(rootEl.textContent||'');
if(!newRendered) return;
const _maxCommon=Math.min(prevRendered.length,newRendered.length);
let _common=0;
while(_common<_maxCommon&&prevRendered.charCodeAt(_common)===newRendered.charCodeAt(_common)) _common+=1;
if(_common<=0) return;
let consumed=0;
const _walk=(node)=>{
if(!node||consumed>_common) return;
const isText=node.nodeType===3||node.type==='text';
if(isText){
const len=(node.textContent||'').length;
const start=consumed;
consumed+=len;
// Text node inside a fade span that starts before the common-prefix
// boundary → mute the span (drop is-new, keeping the word visible
// without replaying its animation).
if(start<_common){
const parent=node.parentNode;
if(parent&&/\bstream-fade-word\b/.test(parent.className||'')&&/\bis-new\b/.test(parent.className||'')){
if(parent.classList&&typeof parent.classList.remove==='function'){
parent.classList.remove('is-new');
}else{
parent.className=String(parent.className||'').replace(/\bis-new\b/g,'').replace(/\s{2,}/g,' ').trim();
}
}
}
return;
}
const kids=node.childNodes||node.children;
if(kids){ for(let i=0;i<kids.length;i++) _walk(kids[i]); }
};
_walk(rootEl);
}
// Exposed for the transparent-stream fade prose reconciler in ui.js
// (same pattern as __anchorProseIncrementalNode above): the no-cursor
// rebuild branch of _refreshTransparentFadeProseRow snapshots the rendered
// text before clearing and re-applies this mute so only genuinely-new tail
// words animate (#7082 review). The helper is stateless, so unlike
// __anchorProseIncrementalNode it never needs to be cleared per-stream.
if(typeof window!=='undefined') window.__streamFadeMuteRenderedPrefix=_streamFadeMuteRenderedPrefix;
function _streamFadePauseAfter(text, paragraphBreakIndex){
if(paragraphBreakIndex>=0) return 90;
const trimmed=String(text||'').trimEnd();
if(/[.!?]["\x27)\]]*$/.test(trimmed)) return 45;
if(/[:;]["\x27)\]]*$/.test(trimmed)) return 30;
return 0;
}
function _streamFadeNextText(targetText){
targetText=String(targetText||'');
const now=performance.now();
if(!targetText){
const hadVisible=!!_streamFadeVisibleText;
_resetStreamFadeState();
return {text:'', caughtUp:true, changed:hadVisible};
}
if(!_streamFadeVisibleText||!targetText.startsWith(_streamFadeVisibleText)){
// Markdown/tool stripping can rewrite the visible prefix. Shrink the
// playout cursor to the common prefix instead of resetting to zero —
// a full reset would replay the fade animation on every already-visible
// word whenever the display text briefly rewinds (e.g. tool-call XML
// being stripped mid-stream). Only when nothing overlaps does the playout
// need a true from-scratch start.
let _commonLen=0;
const _maxCommon=Math.min(_streamFadeVisibleText.length,targetText.length);
while(_commonLen<_maxCommon&&_streamFadeVisibleText.charCodeAt(_commonLen)===targetText.charCodeAt(_commonLen)) _commonLen+=1;
if(_commonLen>0){
_streamFadeVisibleText=targetText.slice(0,_commonLen);
_streamFadeVisibleWords=_streamFadeWordCountOf(_streamFadeVisibleText);
_streamFadeWordCarry=0;
_streamFadeLastTickMs=0;
_streamFadeStartedAt=0;
// changed:true forces the DOM to sync to the shrunken prefix this
// frame (dropping the rewind tail). The rebuild mutes the common
// prefix so no fade animation is replayed.
return {text:_streamFadeVisibleText,caughtUp:_streamFadeVisibleText===targetText,changed:true};
}
_resetStreamFadeState();
}
if(!_streamFadeLastTickMs){
_streamFadeLastTickMs=now;
_streamFadeStartedAt=now;
}
if(_streamFadeVisibleText===targetText) return {text:_streamFadeVisibleText,caughtUp:true,changed:false};
const remaining=targetText.slice(_streamFadeVisibleText.length);
const backlogWords=_streamFadeWordCountOf(remaining);
const targetWords=_streamFadeVisibleWords+backlogWords;
const elapsedMs=Math.max(16,Math.min(120,now-_streamFadeLastTickMs));
_streamFadeLastTickMs=now;
// OpenWebUI fades the actual arriving tokens, so long/fast responses naturally
// appear to accelerate. Hermes has a playout buffer, so track incoming word
// velocity and play out faster than it instead of using a metronomic cadence.
// LLM telemetry is usually tokens/sec, but the UI reveals words. A fixed word
// cadence can look stuck even when token throughput is high, so combine:
// 1) live target-word arrival velocity, 2) backlog pressure, 3) time ramp.
if(!_streamFadeLastArrivalMs){
_streamFadeLastArrivalMs=now;
_streamFadeLastTargetWords=targetWords;
} else if(targetWords>_streamFadeLastTargetWords){
const arrivalElapsedMs=Math.max(16, now-_streamFadeLastArrivalMs);
const instantArrivalWps=(targetWords-_streamFadeLastTargetWords)*1000/arrivalElapsedMs;
// EWMA smooths bursty token chunks without hiding sustained fast output.
_streamFadeArrivalWps=_streamFadeArrivalWps
? (_streamFadeArrivalWps*0.65 + instantArrivalWps*0.35)
: instantArrivalWps;
_streamFadeLastArrivalMs=now;
_streamFadeLastTargetWords=targetWords;
} else if(targetWords<_streamFadeLastTargetWords){
_streamFadeLastTargetWords=targetWords;
_streamFadeLastArrivalMs=now;
_streamFadeArrivalWps=0;
}
if(now<_streamFadeHoldUntilMs){
return {text:_streamFadeVisibleText,caughtUp:false,changed:false};
}
const streamAgeSeconds=Math.max(0, (now-(_streamFadeStartedAt||now))/1000);
const baseWps=22 + Math.min(streamAgeSeconds*2.5, 28); // 22 → 50 wps over long answers
const arrivalWps=_streamFadeArrivalWps ? Math.min(_streamFadeArrivalWps*1.05 + 8, 160) : 0;
const backlogWps=backlogWords>0 ? Math.min(22 + backlogWords*1.1, 160) : 0;
const wordsPerSecond=Math.min(160, Math.max(baseWps, arrivalWps, backlogWps));
const speedFadeRatio=Math.max(0,Math.min(1,(wordsPerSecond-50)/(160-50)));
_streamFadeCurrentMs=Math.round(_STREAM_FADE_MS+(_STREAM_FADE_MAX_MS-_STREAM_FADE_MS)*speedFadeRatio);
_streamFadeWordCarry+=elapsedMs*wordsPerSecond/1000;
if(!_streamFadeVisibleText) _streamFadeWordCarry=Math.max(_streamFadeWordCarry,1);
let wordsToReveal=Math.floor(_streamFadeWordCarry);
// At very high throughput, cap each frame to a small readable wave. Sustained
// playback still catches up, but whole paragraphs no longer pop in at once.
const waveCap=backlogWords>=160?3:2;
wordsToReveal=Math.min(wordsToReveal,waveCap,backlogWords);
if(wordsToReveal<1) return {text:_streamFadeVisibleText,caughtUp:false,changed:false};
_streamFadeWordCarry=Math.max(0,_streamFadeWordCarry-wordsToReveal);
let cut=0;
const wordRe=/(\s*\S+\s*)/g;
let match;
while(wordsToReveal>0&&(match=wordRe.exec(remaining))){
cut=wordRe.lastIndex;
wordsToReveal-=1;
}
if(cut<=0) cut=Math.min(remaining.length,4);
const chunk=remaining.slice(0,cut);
const paragraphMatch=chunk.match(/\n\s*\n/);
const paragraphBreak=paragraphMatch ? paragraphMatch.index : -1;
if(paragraphMatch) cut=paragraphBreak+paragraphMatch[0].length;
const revealed=remaining.slice(0,cut);
_streamFadeVisibleText+=revealed;
_streamFadeVisibleWords+=_streamFadeWordCountOf(revealed);
const pauseMs=_streamFadePauseAfter(revealed,paragraphBreak);
if(pauseMs) _streamFadeHoldUntilMs=now+pauseMs;
if(_streamFadeVisibleText.length>targetText.length) _streamFadeVisibleText=targetText;
return {text:_streamFadeVisibleText,caughtUp:_streamFadeVisibleText===targetText,changed:true};
}
function _renderStreamingFadeMarkdown(displayText){
if(!assistantBody) return true;
const next=_streamFadeNextText(displayText);
if(!next.changed) return next.caughtUp;
assistantBody.classList.add('stream-fade-active');
if(!_shouldUseTransparentStreamFade()){
if(!_smdParser&&window.smd){
if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;}
_smdNewParser(assistantBody,true);
}
if(_smdParser){
_smdWrite(next.text,true);
}else{
assistantBody.innerHTML=renderMd ? renderMd(next.text||'') : esc(next.text||'');
_sanitizeSmdLinks(assistantBody);
}
_streamFadeDomText=String(next.text||'');
return next.caughtUp;
}
if(_smdParser){
_smdEndParser();
assistantBody.textContent='';
_streamFadeDomText='';
}
_smdReconnect=false;
if(!_streamFadeDomText&&assistantBody.textContent){
assistantBody.textContent='';
}
if(!String(next.text||'').startsWith(_streamFadeDomText)){
assistantBody.textContent='';
_streamFadeDomText='';
}
const delta=String(next.text||'').slice(_streamFadeDomText.length);
if(delta) assistantBody.appendChild(document.createTextNode(delta));
_streamFadeDomText=String(next.text||'');
return next.caughtUp;
}
function _streamFadeCurrentDisplayText(){
const parsed=_parseStreamState();
return segmentStart===0
? parsed.displayText
: _stripXmlToolCalls(assistantText.slice(segmentStart));
}
function _drainStreamFadeBeforeDone(onDone){
const drainStartedAt=performance.now();
let forcedDone=false;
const step=()=>{
if(!assistantBody){onDone();return;}
const target=_streamFadeCurrentDisplayText();
const caughtUp=_renderStreamingFadeMarkdown(target);
const anchorProcessText=_streamFadeDomText||target;
if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText);
scrollIfPinned();
if(caughtUp){
// parser_end can flush pending markdown text; include that final text in
// the fade wait instead of replacing it immediately in renderMessages().
if(_smdParser) _smdEndParser();
// Let the last released words visibly finish their stagger + fade before
// the final renderMessages() DOM replacement removes the live spans.
const remainingAnimationMs=Math.max(_STREAM_FADE_MS, _streamFadeLatestAnimationEndAt-performance.now());
setTimeout(onDone, Math.min(remainingAnimationMs, _STREAM_FADE_DONE_MAX_MS));
return;
}
// Final SSE `done` means the canonical completed session is available.
// The optional word-fade playout must not keep that completed answer
// hidden behind the live Thinking state for large/bursty responses.
if(!forcedDone&&performance.now()-drainStartedAt>=_STREAM_FADE_DONE_DRAIN_MAX_MS){
forcedDone=true;
if(_smdParser) _smdEndParser();
onDone();
return;
}
setTimeout(()=>requestAnimationFrame(step), 33);
};
step();
}
function _flushPendingSegmentRender(options={}){
const force=!!(options&&options.force);
const skipAnchorProcessProse=!!(options&&options.skipAnchorProcessProse);
if(!assistantBody||(!force&&!_renderPending)) return;
// #6449: guard — this stream's session is no longer the active pane.
// Callers already gate on _isActiveSession(), but add the guard here too
// so any future call-site cannot leak rendering into the wrong session.
if(!_isActiveSession()) return;
if(_renderPending) _cancelAnimationFramePendingStreamRender();
const displayText=segmentStart===0
? _parseStreamState().displayText
: _stripXmlToolCalls(assistantText.slice(segmentStart));
if(_smdParser){
_smdWrite(displayText);
} else if(window.smd){
// Parser was nulled out (e.g. by a prior segment end) but smd is
// available — recreate it on the existing element. Uses the non-fade
// renderer to match standard rendering, avoiding O(n²) innerHTML
// churn on long responses (#4704). Clear any content the renderMd()
// fallback already wrote first: _smdNewParser resets _smdWrittenText to
// '' but does NOT clear the element, so a following _smdWrite(displayText)
// would append the full accumulated segment ON TOP of the existing
// fallback render and duplicate the live text.
assistantBody.innerHTML='';
_smdNewParser(assistantBody, false);
if(_smdParser) _smdWrite(displayText);
} else if(renderMd){
assistantBody.innerHTML=renderMd(displayText);
} else {
assistantBody.innerHTML=esc(displayText);
}
if(!skipAnchorProcessProse) _upsertAnchorProcessProse(displayText,{sealed:force});
if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText);
}
function _resetAssistantSegment(){
assistantRow=null;
assistantBody=null;
segmentStart=assistantText.length;
_freshSegment=true;
_smdEndParser();
_resetStreamFadeState();
}
function _rememberRunJournalCursor(e){
const raw=String(e&&e.lastEventId||'').trim();
if(!raw) return;
const tail=raw.includes(':')?raw.slice(raw.lastIndexOf(':')+1):raw;
const seq=Number.parseInt(tail,10);
if(Number.isFinite(seq)&&seq>_lastRunJournalSeq){
_lastRunJournalSeq=seq;
_lastRunJournalEventId=raw;
// Mirror the advanced cursor onto the persisted INFLIGHT entry. persistInflightState()
// saves `inflight.lastRunJournalSeq`, and a hard reload / reattach reads it back as the
// `after_seq` replay floor (see attachLiveStream reconnecting init). Without this write
// the persisted seq stayed 0, so a reload restored `lastAssistantText` and then replayed
// the run journal from the zero floor (after_seq of 0) ON TOP of it — duplicating
// already-rendered live reply content. Throttled persist keeps this off the hot token path. (#3401 reconnect dup)
const inflight=INFLIGHT[activeSid];
if(inflight){
inflight.lastRunJournalSeq=seq;
inflight.lastRunJournalEventId=raw;
if(typeof _throttledPersist==='function') _throttledPersist();
}
}
}
function _runJournalReplayAfterSeq(){
return Math.max(0,_lastRunJournalSeq||0);
}
function _runJournalReplayParams(){
// `replay=1` documents frontend intent. The server selects replay when the
// stream id no longer has a live worker; `after_seq` prevents duplicated
// journal events after this EventSource has already rendered part of the
// same run. `after_event_id` keeps that cursor run-aware so a stale cursor
// from an earlier interrupted stream cannot suppress a newer stream whose
// sequence numbers started over from 1.
return `&replay=1&after_seq=${encodeURIComponent(String(_runJournalReplayAfterSeq()))}&after_event_id=${encodeURIComponent(_lastRunJournalEventId||'')}`;
}
function _stableStringify(value){
const normalize=(v)=>{
if(v===null||typeof v!=='object') return v;
if(Array.isArray(v)) return v.map(normalize);
const obj={};
const keys=Object.keys(v).sort();
for(const key of keys){
obj[key]=normalize(v[key]);
}
return obj;
};
try{
return JSON.stringify(normalize(value));
}catch(_){
return String(value||'');
}
}
function _hashString(value){
let hash=2166136261;
for(let i=0;i<String(value||'').length;i++){
hash^=String(value||'').charCodeAt(i);
hash=Math.imul(hash,16777619);
}
return (hash>>>0).toString(16);
}
function _toolCallSignature(d, activityBurstId, activitySegmentSeq){
const name=String(d&&d.name||'').trim().toLowerCase();
const bid=Number(activityBurstId);
const seq=Number(activitySegmentSeq);
const args=d&&d.args;
return `${name}|${Number.isFinite(bid)?bid:0}|${Number.isFinite(seq)?seq:0}|${_stableStringify(args)}`;
}
function _liveToolTid(d, activityBurstId, activitySegmentSeq){
const explicit=String(d&&(d.tid||d.id||d.tool_call_id||d.tool_use_id||d.call_id)||'').trim();
if(explicit) return explicit;
return `live-${activeSid}-${_hashString(_toolCallSignature(d,activityBurstId,activitySegmentSeq))}`;
}
function _coerceLiveToolCallSignature(tc, activityBurstId, activitySegmentSeq){
if(tc&&typeof tc==='object' && !tc._liveToolCallSignature){
tc._liveToolCallSignature=_toolCallSignature(tc,activityBurstId,activitySegmentSeq);
}
return tc&&tc._liveToolCallSignature||'';
}
function _findPendingLiveToolCallIndex(toolCalls, opts){
if(!Array.isArray(toolCalls)) return -1;
const wantedTid=opts&&opts.tid||'';
const wantedName=String(opts&&opts.name||'');
const wantedSig=opts&&opts.signature||'';
const wantedBurst=Number(opts&&opts.activityBurstId);
const wantedSeq=Number(opts&&opts.activitySegmentSeq);
const allowDone=!!(opts&&opts.allowDone);
const matchName=(candidate)=>{
return !candidate||!candidate.name||!wantedName ? false : String(candidate.name)===wantedName;
};
if(wantedTid){
for(let i=toolCalls.length-1;i>=0;i--){
const candidate=toolCalls[i];
if(!candidate||typeof candidate!=='object') continue;
if(!allowDone&&candidate.done===true) continue;
const candidateTid=String(candidate.tid||candidate.id||candidate.tool_call_id||candidate.tool_use_id||candidate.call_id||'');
if(candidateTid&&candidateTid===wantedTid) return i;
}
}
if(wantedSig){
for(let i=toolCalls.length-1;i>=0;i--){
const candidate=toolCalls[i];
if(!candidate||typeof candidate!=='object') continue;
if(!allowDone&&candidate.done===true) continue;
const canonicalSig=_coerceLiveToolCallSignature(
candidate,
Number.isFinite(wantedBurst)?wantedBurst:activityBurstFallbackFromCandidate(candidate),
Number.isFinite(wantedSeq)?wantedSeq:activitySegmentSeqFallbackFromCandidate(candidate),
);
if(canonicalSig&&canonicalSig===wantedSig) return i;
}
}
for(let i=toolCalls.length-1;i>=0;i--){
const candidate=toolCalls[i];
if(!candidate||typeof candidate!=='object') continue;
if(!allowDone&&candidate.done===true) continue;
if(!matchName(candidate)) continue;
const candidateSeq=Number(candidate.activitySegmentSeq);
const candidateBid=Number(candidate.activityBurstId);
if(Number.isFinite(wantedSeq)&&Number.isFinite(candidateSeq)&&candidateSeq!==wantedSeq) continue;
if(Number.isFinite(wantedBurst)&&Number.isFinite(candidateBid)&&candidateBid!==wantedBurst) continue;
return i;
}
return -1;
}
function activityBurstFallbackFromCandidate(candidate){
return Number(candidate && candidate.activityBurstId);
}
function activitySegmentSeqFallbackFromCandidate(candidate){
return Number(candidate && candidate.activitySegmentSeq);
}
function _coerceLiveToolCallSeq(candidate){
const raw=Number.isFinite(candidate)?candidate:Number(candidate&&candidate.activitySegmentSeq);
return Number.isFinite(raw)&&raw>0?raw:undefined;
}
function _currentLiveToolAnchor(){
const segmentSeq=Number(
assistantRow&&assistantRow.getAttribute('data-live-segment-seq')||
_assistantSegmentSeq||
_currentLiveSegmentSeq||
0
);
const burst=Number(_currentActivityBurstId);
return {
segmentSeq:Number.isFinite(segmentSeq)&&segmentSeq>0?segmentSeq:undefined,
burstId:Number.isFinite(burst)?burst:0,
};
}
function upsertLiveToolCall(d, phase){
if(!d||d.name==='clarify') return null;
const name=String(d&&d.name||'').trim();
if(!name) return null;
const current=_currentLiveToolAnchor();
const inflight=INFLIGHT[activeSid] || (INFLIGHT[activeSid]={
messages:[...S.messages],
uploaded:[...uploaded],
toolCalls:[],
});
if(!Array.isArray(inflight.toolCalls)) inflight.toolCalls=[];
if(!Array.isArray(inflight.messages)) inflight.messages=[...(inflight.messages||[])];
const explicitTid=String(d&&d.tid||d&&d.id||d&&d.tool_call_id||d&&d.tool_use_id||d&&d.call_id||'').trim();
const isComplete=phase==='complete';
let signature=_toolCallSignature(d,current.burstId,current.segmentSeq);
let index=-1;
if(explicitTid){
index=_findPendingLiveToolCallIndex(inflight.toolCalls,{
tid:explicitTid,
allowDone:isComplete,
});
}
if(index<0){
index=_findPendingLiveToolCallIndex(inflight.toolCalls,{
signature,
name,
activityBurstId:current.burstId,
activitySegmentSeq:current.segmentSeq,
allowDone:isComplete,
});
}
if(index<0 && isComplete && !explicitTid){
index=_findPendingLiveToolCallIndex(inflight.toolCalls,{
name,
activityBurstId:current.burstId,
allowDone:true,
});
}
let tc=null;
if(index>=0&&inflight.toolCalls[index]){
tc=inflight.toolCalls[index];
}
if(!tc){
tc={
name,
preview:String(d.preview||''),
args:d.args||{},
snippet:'',
done:isComplete,
tid:explicitTid||_liveToolTid(d,current.burstId,current.segmentSeq),
activityBurstId:current.burstId,
activitySegmentSeq:_coerceLiveToolCallSeq(current.segmentSeq),
};
if(!isComplete){
tc.started_at=Date.now()/1000;
}
if(isComplete) tc._createdByComplete=true;
inflight.toolCalls.push(tc);
if(!signature){
signature=_toolCallSignature(tc,tc.activityBurstId,tc.activitySegmentSeq);
}
} else {
if(!tc.name) tc.name=name;
if(!tc._liveToolCallSignature){
tc._liveToolCallSignature=_toolCallSignature(tc,tc.activityBurstId,tc.activitySegmentSeq);
}
}
if(isComplete){
if(d.preview){
tc.snippet=tc.snippet||String(d.preview||'');
if(!tc.preview) tc.preview=String(d.preview||'');
}
} else {
tc.preview=String(d.preview||tc.preview||'');
}
if(d.args!==undefined) tc.args=d.args;
if(d.snippet!==undefined) tc.snippet=d.snippet;
tc._liveToolCallSignature = _toolCallSignature(tc,tc.activityBurstId,tc.activitySegmentSeq);
tc.activityBurstId = Number.isFinite(Number(tc.activityBurstId))
? Number(tc.activityBurstId)
: current.burstId;
const currentSegmentSeq=_coerceLiveToolCallSeq(current.segmentSeq);
const startSeq=_coerceLiveToolCallSeq(tc._toolCallStartSeq);
const inferredSeq=_coerceLiveToolCallSeq(tc.activitySegmentSeq);
if(!isComplete){
if(inferredSeq===undefined && currentSegmentSeq!==undefined){
tc.activitySegmentSeq=currentSegmentSeq;
} else if(inferredSeq!==undefined){
tc.activitySegmentSeq=inferredSeq;
}
tc._toolCallStartSeq=tc.activitySegmentSeq;
} else if(startSeq!==undefined){
tc.activitySegmentSeq=startSeq;
} else if(inferredSeq!==undefined){
tc.activitySegmentSeq=inferredSeq;
}
if(isComplete){
tc.done=true;
if(typeof d.is_error==='boolean') tc.is_error=d.is_error;
if(d.duration!==undefined) tc.duration=d.duration;
if(tc.started_at===undefined||tc.started_at===null) tc.started_at=Date.now()/1000;
if(!tc.tid) tc.tid=explicitTid||_liveToolTid(d,tc.activityBurstId,tc.activitySegmentSeq);
} else {
tc.done=false;
tc.started_at=tc.started_at||Date.now()/1000;
}
S.toolCalls=inflight.toolCalls;
persistInflightState();
return tc;
}
let _lastRenderMs=0;
// Parse-result cache: _scheduleRender can accept a pre-computed _parseStreamState()
// from the token event handler, avoiding a duplicate O(n) scan inside _doRender
// when the rAF fires before the next token arrives.
let _cachedParsed=null;
let _cachedParsedText='';
let _cachedParsedReasoning='';
function _scheduleRender(parsed){
// If caller provides a pre-computed parse result, cache it for _doRender.
if(parsed){
_cachedParsed=parsed;
_cachedParsedText=assistantText;
_cachedParsedReasoning=liveReasoningText;
}
if(_renderPending) return;
if(_streamFinalized) return; // Bug A: don't schedule new rAF after stream finalized
// #6449: guard — this stream's session is no longer the active frontend pane.
// Drop the scheduled render instead of writing into a detached or wrong-session DOM.
// Callers (token/interim_assistant handlers) already gate on _isActiveSession(), but
// the rAF/setTimeout window between schedule and execution can outlive a session switch.
if(!_isActiveSession()) return;
_renderPending=true;
// Cap render rate to ~15fps. The browser's rAF fires at 60fps, but each DOM
// update takes 50-150ms on large sessions. During GC pauses, rAF callbacks
// accumulate and then execute all at once, blocking the main thread for
// multi-second stretches and crashing the renderer (Chrome error code 4/5).
// Throttling to 66ms intervals prevents this pileup without noticeable
// visual degradation — streaming text updates still feel immediate.
// performance.now() is monotonic so tab suspend/resume and NTP adjustments
// cannot produce negative or enormous deltas.
const sinceLastMs=performance.now()-_lastRenderMs;
const _doRender=()=>{
_pendingRafHandle=null;
_renderPending=false;
// Guard: a pending setTimeout+rAF can outlive stream finalization.
if(_streamFinalized) return;
// #6449: guard — the frontend session changed between rAF schedule and execution.
// Writing DOM into this stream's assistantBody would leak text into the wrong pane.
if(!_isActiveSession()) return;
// Mobile scroll-jank guard: temporarily disable overflow-anchor before DOM
// writes to suppress Chromium scroll re-anchoring during streaming growth.
if(typeof window._fixMobileScrollJank==='function') window._fixMobileScrollJank();
_lastRenderMs=performance.now();
const parsed=_cachedParsed&&_cachedParsedText===assistantText&&_cachedParsedReasoning===liveReasoningText ? _cachedParsed : _parseStreamState();
_cachedParsed=null;
_renderLiveThinking(parsed);
const displayText = segmentStart===0
? parsed.displayText // first segment: uses think-tag stripping
: _stripXmlToolCalls(assistantText.slice(segmentStart));
let anchorProcessText=displayText;
if(assistantBody){
if(_shouldUseLiveProseFade()){
const caughtUp=_renderStreamingFadeMarkdown(displayText);
anchorProcessText=_streamFadeDomText||'';
if(!caughtUp&&!_streamFinalized){
setTimeout(()=>_scheduleRender(), 33);
}
} else {
assistantBody.classList.remove('stream-fade-active');
_resetStreamFadeState();
if(!_smdParser&&window.smd){
// On reconnect: prior content in assistantBody came from a different smd parser run.
// Clear it and start fresh — renderMessages() on done will restore the full content.
if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;}
_smdNewParser(assistantBody);
}
if(_smdParser){
_smdWrite(displayText);
} else {
// Fallback: smd not loaded yet, reconnect session, or smd unavailable — use renderMd
// for every live segment. Without this, the first segment inserts raw
// parsed.displayText and users see unformatted markdown until done.
const fallbackText = segmentStart===0
? parsed.displayText
: _stripXmlToolCalls(assistantText.slice(segmentStart));
assistantBody.innerHTML = renderMd ? renderMd(fallbackText) : esc(fallbackText);
}
}
if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText);
}
if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText);
scrollIfPinned();
_throttledSnapshotLiveTurn();
};
const frameIntervalMs=_shouldUseLiveProseFade()?33:66;
if(sinceLastMs>=frameIntervalMs){
_pendingRafHandle=requestAnimationFrame(_doRender);
} else {
_pendingRafHandle=setTimeout(()=>requestAnimationFrame(_doRender), frameIntervalMs-sinceLastMs);
}
}
function _completeAutomaticCompressionOnLiveProgress(sessionId){
const sid=String(sessionId||'');
const hasRunningLiveCard=!!document.querySelector('[data-live-compression-card="1"][data-compression-started-at]');
const hasRunningState=!!(window._compressionUi&&window._compressionUi.automatic&&window._compressionUi.phase==='running'&&(!sid||!window._compressionUi.sessionId||String(window._compressionUi.sessionId)===sid));
if(!hasRunningLiveCard&&!hasRunningState) return false;
_ensureAnchorCompressionCompletedOnLiveProgress(sid);
if(typeof appendLiveCompressionCard==='function'){
appendLiveCompressionCard({
sessionId:sid,
phase:'done',
automatic:true,
message:'Context auto-compressed',
});
}
return true;
}
function _wireSSE(source){
const existingLive=LIVE_STREAMS[activeSid];
if(existingLive&&existingLive.source&&existingLive.source!==source){
try{if(existingLive.source.readyState!==2)existingLive.source.close();}catch(_){ }
}
LIVE_STREAMS[activeSid]={streamId,source};
// Note on #631 Bug B: the original PR description stated the server
// "replays buffered token events" on reconnect, and proposed resetting
// the accumulators here so the re-sent tokens wouldn't double the prefix.
// That is NOT how the server actually works — api/routes._handle_sse_stream
// reads a one-shot queue.Queue() that delivers each event to exactly one
// consumer; a reconnect picks up from the current queue position and gets
// only events produced during the outage. Resetting the accumulators here
// would wipe the already-displayed content and restart the response from
// the first post-reconnect token — a real data-loss regression.
//
// The "doubled response" / "stuck cursor" symptom is fully explained by
// Bug A (trailing rAF after `done` inserting a new live-turn wrapper) —
// the fixes below (_streamFinalized guard + cancelAnimationFrame in the
// terminal handlers) address it without needing a reset here.
source.addEventListener('token',e=>{
if(_terminalStateReached||_streamFinalized) return;
const d=JSON.parse(e.data);
assistantText+=d.text;
syncInflightAssistantMessage();
if(!S.session||S.session.session_id!==activeSid) return;
_completeAutomaticCompressionOnLiveProgress(activeSid);
if(_freshSegment) appendThinking('', _liveThinkingPlacement());
// Once the assistant row exists its creation gate is already satisfied, and
// the throttled _doRender re-parses once per frame anyway — so the per-token
// full-text parse here is pure waste (O(n)/token -> O(n^2) over the answer).
// Still call ensureAssistantRow() every token exactly as before (cheap; it
// also starts a new segment on a post-tool _freshSegment). Only the parse is
// skipped, and only once the row exists. (#5455 WS2.3)
if(assistantRow){
ensureAssistantRow();
_scheduleRender();
}else{
const parsed=_parseStreamState();
if(String((parsed&&parsed.displayText)||'').trim()) ensureAssistantRow();
_scheduleRender(parsed);
}
});
source.addEventListener('interim_assistant',e=>{
if(_terminalStateReached||_streamFinalized) return;
const d=JSON.parse(e.data);
const visible=String(d&&d.text?d.text:'').trim();
const alreadyStreamed=!!(d&&d.already_streamed);
const reasoningEcho=!!(d&&d.reasoning_echo);
if(!visible){
return;
}
if(reasoningEcho) _stripLiveReasoningEcho(visible);
liveReasoningText='';
if(alreadyStreamed){
if(!S.session||S.session.session_id!==activeSid){
recordActivityBoundary();
_resetAssistantSegment();
return;
}
_completeAutomaticCompressionOnLiveProgress(activeSid);
const parsed=_parseStreamState();
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow){
ensureAssistantRow(true);
_flushPendingSegmentRender({force:true});
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
if(typeof closeCurrentLiveActivityGroup==='function') closeCurrentLiveActivityGroup();
recordActivityBoundary();
}
_resetAssistantSegment();
return;
}
assistantText += assistantText ? `\n\n${visible}` : visible;
visibleInterimSnippets.push(visible);
syncInflightAssistantMessage();
if(!S.session||S.session.session_id!==activeSid){
recordActivityBoundary();
_resetAssistantSegment();
return;
}
_completeAutomaticCompressionOnLiveProgress(activeSid);
ensureAssistantRow(true);
if(assistantRow) assistantRow.setAttribute('data-interim','1');
_flushPendingSegmentRender({force:true,skipAnchorProcessProse:true});
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
if(typeof closeCurrentLiveActivityGroup==='function') closeCurrentLiveActivityGroup();
_applyToAnchor('interim_assistant',d,e);
// Collapse old interim notes once more than INTERIM_COLLAPSE_THRESHOLD accumulate.
const INTERIM_COLLAPSE_THRESHOLD=3;
if(visibleInterimSnippets.length>INTERIM_COLLAPSE_THRESHOLD&&assistantRow){
const blocks=assistantRow.parentElement;
if(blocks){
const anchorSceneOwnsLive=!!(blocks.closest&&blocks.closest('[data-anchor-scene-live-owner="1"]'));
if(anchorSceneOwnsLive){
blocks.querySelectorAll('.interim-collapse-toggle').forEach(el=>el.remove());
}else{
const allInterim=Array.from(blocks.querySelectorAll('[data-interim="1"]'));
const toHide=allInterim.slice(0,allInterim.length-INTERIM_COLLAPSE_THRESHOLD);
let toggle=blocks.querySelector('.interim-collapse-toggle');
if(!toggle){
toggle=document.createElement('span');
toggle.className='interim-collapse-toggle';
// No per-element listener: clicks are handled by a delegated
// document-level handler (see _interimCollapseDelegatedClick) so
// the toggle keeps working after a live-turn DOM restore
// (snapshotLiveTurnHtmlForSession/restoreLiveTurnHtmlForSession
// rebuild via innerHTML, which would drop a direct listener and
// leave the collapsed notes permanently unreachable). The
// threshold rides on the markup so the handler stays stateless.
toggle.dataset.threshold=String(INTERIM_COLLAPSE_THRESHOLD);
if(toHide.length) toHide[0].before(toggle);
}
// Skip re-collapse when the user expanded manually; always update the stored count.
if(!toggle.dataset.expanded){
toHide.forEach(el=>el.classList.add('interim-collapsed'));
}
const stillHidden=blocks.querySelectorAll('[data-interim="1"].interim-collapsed').length;
if(stillHidden) toggle.textContent='Show '+stillHidden+' earlier update'+(stillHidden===1?'':'s');
}
}
}
recordActivityBoundary();
_resetAssistantSegment();
_scheduleRender();
});
source.addEventListener('reasoning',e=>{
if(_terminalStateReached||_streamFinalized) return;
if(!_ownsActiveStreamOrBackground()) return;
const d=JSON.parse(e.data);
const text=d.text||'';
reasoningText += text;
liveReasoningText += text;
if(d.text&&S.session&&S.session.session_id===activeSid) _completeAutomaticCompressionOnLiveProgress(activeSid);
syncInflightAssistantMessage();
if(text&&S.session&&S.session.session_id===activeSid&&S.activeStreamId===streamId){
const liveThinkingText=_liveThinkingText();
const anchorReasoningFallback={};
if(!_upsertAnchorReasoning(liveThinkingText, anchorReasoningFallback)){
_updateLiveThinkingCard(liveThinkingText,{
...anchorReasoningFallback,
anchorRenderFallback:true,
sessionId:activeSid,
streamId,
});
}
}
});
source.addEventListener('tool',e=>{
if(_terminalStateReached||_streamFinalized) return;
if(!S.session||S.session.session_id!==activeSid||S.activeStreamId!==streamId) return;
const d=JSON.parse(e.data);
if(d.name==='clarify') return;
_completeAutomaticCompressionOnLiveProgress(activeSid);
const tc=upsertLiveToolCall(d,'start');
if(!tc) return;
const pendingDisplayTextBeforeTool=segmentStart===0
? (_parseStreamState().displayText||'')
: _stripXmlToolCalls(assistantText.slice(segmentStart));
if(String(pendingDisplayTextBeforeTool||'').trim()) _upsertAnchorProcessProse(pendingDisplayTextBeforeTool,{sealed:true});
_applyToAnchor('tool',{...d,...tc},e);
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
if(!S.session||S.session.session_id!==activeSid) return;
// Provider reasoning/thinking is a Worklog Thinking Card, separate from
// tool cards. Close the current live card before appending a tool row.
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
liveReasoningText='';
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
const pendingDisplayText=segmentStart===0
? (_parseStreamState().displayText||'')
: _stripXmlToolCalls(assistantText.slice(segmentStart));
if((assistantRow&&assistantBody)||String(pendingDisplayText||'').trim()){
ensureAssistantRow(true);
}
_flushPendingSegmentRender({force:true});
appendLiveToolCard(tc,{sessionId:activeSid,streamId});
snapshotLiveTurn();
_freshSegment=true;
_smdEndParser();
_resetAssistantSegment();
scrollIfPinned();
});
source.addEventListener('tool_complete',e=>{
if(_terminalStateReached||_streamFinalized) return;
if(!S.session||S.session.session_id!==activeSid||S.activeStreamId!==streamId) return;
const d=JSON.parse(e.data);
if(d.name==='clarify') return;
_completeAutomaticCompressionOnLiveProgress(activeSid);
const tc=upsertLiveToolCall(d,'complete');
if(!tc) return;
tc.is_error=!!d.is_error;
const pendingDisplayTextBeforeComplete=segmentStart===0
? (_parseStreamState().displayText||'')
: _stripXmlToolCalls(assistantText.slice(segmentStart));
if(String(pendingDisplayTextBeforeComplete||'').trim()) _upsertAnchorProcessProse(pendingDisplayTextBeforeComplete,{sealed:true});
_applyToAnchor('tool_complete',{...d,...tc,is_error:!!d.is_error},e);
if(typeof noteWorkspaceMutationsFromToolCall==='function') noteWorkspaceMutationsFromToolCall(tc);
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
if(!S.session||S.session.session_id!==activeSid) return;
_maybeNotifyPersistentStateSaved(tc);
if(typeof refreshOpenPreviewIfMutated==='function') refreshOpenPreviewIfMutated();
if(tc._createdByComplete){
const pendingDisplayText=segmentStart===0
? (_parseStreamState().displayText||'')
: _stripXmlToolCalls(assistantText.slice(segmentStart));
if((assistantRow&&assistantBody)||String(pendingDisplayText||'').trim()){
ensureAssistantRow(true);
_flushPendingSegmentRender({force:true});
}
appendLiveToolCard(tc,{sessionId:activeSid,streamId});
_freshSegment=true;
_smdEndParser();
_resetAssistantSegment();
} else {
appendLiveToolCard(tc,{sessionId:activeSid,streamId});
}
snapshotLiveTurn();
scrollIfPinned();
});
// Phase 2: dedicated `todo_state` event carries a full snapshot of
// the upstream TodoStore. We treat it as the single source of truth
// for the Todos panel — never merge, always replace. The handler
// is intentionally cheap: parse, validate, write S.todos, mirror to
// INFLIGHT, schedule a RAF render. Out-of-order events are filtered
// by ts; SSE journal replay is idempotent because snapshots are full.
// Cross-session protection mirrors every other live listener:
// payload.session_id must match activeSid or the event is dropped.
source.addEventListener('todo_state',e=>{
let d;
try{ d=JSON.parse(e.data||'{}'); }catch(_){ return; }
if(!d||typeof d!=='object') return;
// Cross-session double check: payload.session_id is the SSE-side
// filter (some legacy emissions omit it), and S.session.session_id
// is the UI-side filter (a late event that arrives after the user
// already navigated to another session must not pollute S.todos).
// Both must agree with activeSid before we touch global state.
if(d.session_id&&d.session_id!==activeSid) return;
if(!S.session||S.session.session_id!==activeSid) return;
if(!Array.isArray(d.todos)) return;
const incomingTs=Number(d.ts)||0;
const currentTs=(S.todoStateMeta&&Number(S.todoStateMeta.ts))||0;
// Strictly older snapshots are discarded; equal-ts events still
// apply so a compression-source refresh can land on the same
// second as the tool emit it follows.
if(incomingTs&&currentTs&&incomingTs<currentTs) return;
S.todos=d.todos;
S.todoStateMeta={
ts:incomingTs||(Date.now()/1000),
source:String(d.source||'tool'),
version:Number(d.version)||1,
};
const inflight=INFLIGHT[activeSid];
if(inflight){
inflight.todos=S.todos;
inflight.todoStateMeta=S.todoStateMeta;
}
if(typeof persistInflightState==='function') persistInflightState();
if(typeof scheduleTodosRefresh==='function') scheduleTodosRefresh();
});
source.addEventListener('approval',e=>{
const d=JSON.parse(e.data);
_applyToAnchor('approval',d,e);
showApprovalForSession(activeSid, d, d.pending_count || 1);
playAttentionSound(_attentionSoundKey(activeSid,'approval',1));
sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid});
});
source.addEventListener('clarify',e=>{
const d=JSON.parse(e.data);
_applyToAnchor('clarify',d,e);
showClarifyForSession(activeSid, d);
playAttentionSound(_attentionSoundKey(activeSid,'clarify',1));
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid});
});
source.addEventListener('state_saved',e=>{
let d={};
try{ d=JSON.parse(e.data||'{}'); }catch(_){}
if((d.session_id||activeSid)!==activeSid) return;
if(!S.session||S.session.session_id!==activeSid) return;
_applyToAnchor('state_saved',d,e,null,{render:false});
_showPersistentStateToast(d.kind, d.name||'', {created:String(d.action||'').toLowerCase()==='created'});
});
source.addEventListener('title',e=>{
let d={};
try{ d=JSON.parse(e.data||'{}'); }catch(_){}
if((d.session_id||activeSid)!==activeSid) return;
applySessionTitleUpdate(activeSid, d.title);
});
source.addEventListener('title_status',e=>{
let d={};
try{ d=JSON.parse(e.data||'{}'); }catch(_){}
if((d.session_id||activeSid)!==activeSid) return;
try{
console.info('[title]', {
status:String(d.status||''),
reason:String(d.reason||''),
title:String(d.title||''),
raw_preview:String(d.raw_preview||''),
session_id:String(d.session_id||activeSid)
});
}catch(_){}
});
source.addEventListener('context_status',e=>{
let d={};
try{ d=JSON.parse(e.data||'{}'); }catch(_){}
if((d.session_id||activeSid)!==activeSid) return;
const prefill=d.prefill||{};
const status=String(prefill.status||'not_configured');
const label=String(prefill.label||'session recall');
if(status==='loaded'){
setComposerStatus(`Context loaded: ${label}`);
}else if(status==='error'){
setComposerStatus(`Context unavailable: ${label}`);
if(typeof showToast==='function') showToast(`Context unavailable: ${String(prefill.error||label)}`,3600,'warning');
}
});
function _resolveGoalMessage(d){
const key=String(d && d.message_key ? d.message_key : '').trim();
const args=Array.isArray(d && d.message_args) ? d.message_args : [];
const raw=String(d&&d.message||'').trim();
if(key && typeof t==='function'){
try{
const translated=String(t(key,...args));
if(translated && translated!==key)return translated;
}catch(_){}
}
return raw;
}
source.addEventListener('goal',e=>{
try{
const d=JSON.parse(e.data||'{}');
if((d.session_id||activeSid)!==activeSid) return;
const goalState=String(d.state||'').trim();
const goalEvaluatingMessage=t('goal_evaluating_progress');
if(goalState==='evaluating'){
setComposerStatus(goalEvaluatingMessage);
return;
}
const msg=_resolveGoalMessage(d);
if(!msg)return;
_latestGoalStatus={message:msg,decision:d.decision||null,state:goalState||null};
setComposerStatus(msg);
showToast(msg.split('\n')[0],2600);
}catch(_){}
});
source.addEventListener('goal_continue',e=>{
try{
const d=JSON.parse(e.data||'{}');
const sid=d.session_id||activeSid;
const continuation_prompt=String(d.continuation_prompt||d.text||'').trim();
if(!continuation_prompt||sid!==activeSid)return;
_applyToAnchor('goal_continue',d,e);
const _modelState=_chatPayloadModelState();
_pendingGoalContinuation={
sid,
text:continuation_prompt,
model:_modelState.model,
model_provider:_modelState.model_provider,
profile:S.activeProfile||'default',
};
const toast=t('goal_continuing_toast');
const cmsg=_resolveGoalMessage(d);
showToast((toast&&cmsg&&cmsg!==toast)?cmsg.split('\n')[0]:toast,2200);
}catch(_){}
});
// bg_task_complete: terminal(notify_on_complete=true) background process
// exited. Option Z PIVOT: the agent wakeup is started SERVER-SIDE by the
// drain thread (api/background_process._process_one →
// routes.start_session_turn) with NO browser round-trip — so the
// closed-tab case works (parity with CLI/Telegram). The browser does NOT
// re-POST /api/chat/start anymore. This SSE event is pure LIVE-VIEW: if
// a tab is open the server-initiated turn streams live via the normal
// /api/chat/stream EventSource; if the tab is closed the turn still runs
// server-side and persists to the session store.
//
// Idempotency: dedupe by (session_id, event_id) via a Map+TTL ring
// buffer (`_bgTaskCompleteRingBufferAdd`).
//
// Option X: this handler is the in-turn (STREAMS-bound) path. The server
// dual-emits to the persistent session-scoped channel too — the
// `_handleBgTaskCompleteEvent` function below is shared between both
// paths (dedupe only; the wakeup itself is server-side).
source.addEventListener('bg_task_complete',e=>{
if(typeof _handleBgTaskCompleteEvent==='function'){
_handleBgTaskCompleteEvent(e, activeSid, {source:'stream'});
}
});
source.addEventListener('done',e=>{
if(_streamFinalized) return;
_clearStreamEndRecovery();
if(_bailOutOfTerminalEventsFromStaleStream(source)) return;
// Set _streamFinalized IMMEDIATELY — before any fade delay. Without this,
// a stream_end event arriving during the fade window sees
// _streamFinalized=false, calls _restoreSettledSession(), and overwrites
// S.messages with stale server data (issue #3195).
_streamFinalized=true;
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_cancelThrottledSnapshotTimer();
const _doneData=JSON.parse(e.data);
const _doneEvent=e;
const _finishDone=()=>{
// Bug A fix: cancel any pending rAF and mark stream finalized before
// the DOM is settled by renderMessages, so no trailing token/reasoning rAF
// can reintroduce a stale thinking card or duplicate content.
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
// Finalize smd parser — flushes any remaining buffered markdown state
// and runs Prism + copy buttons on the live segment before the DOM is replaced
if(assistantBody){
const _finBody=assistantBody;
_smdEndParser();
requestAnimationFrame(()=>{
if(typeof highlightCode==='function') highlightCode(_finBody);
if(typeof addCopyButtons==='function') addCopyButtons(_finBody);
if(typeof renderKatexBlocks==='function') renderKatexBlocks();
});
} else {
_smdEndParser();
}
const d=_doneData;
_flushReasoningToAnchor();
_applyToAnchor('done',{
status:d.status||'completed',
usage:d.usage||null,
created_at:d.created_at||null,
},_doneEvent);
_scheduleAnchorRegistryCleanup();
_clearAnchorProseIncrementalNode();
const isActiveSession=_isSessionCurrentPane(activeSid);
const isSessionViewed=_isSessionActivelyViewed(activeSid);
const completedSession=d.session||{session_id:activeSid};
const completedSid=completedSession.session_id||activeSid;
const completedMessageCount=completedSession.message_count != null
? completedSession.message_count
: (
Array.isArray(completedSession.messages)
? completedSession.messages.length
: (
(S.session&&((S.session.session_id||activeSid)===completedSid)&&S.session.message_count != null)
? S.session.message_count
: ((Array.isArray(S.messages)&&S.messages.length)||0)
)
);
if(!isSessionViewed && typeof _markSessionCompletionUnread==='function'){
_markSessionCompletionUnread(completedSid, completedMessageCount);
}
if(isSessionViewed) _markSessionViewed(completedSid, completedMessageCount);
_clearOwnerInflightState();
if(typeof _markSessionCompletedInList==='function'){
_markSessionCompletedInList(completedSession, activeSid);
}
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
const shouldFollowOnDone=isActiveSession&&((typeof _shouldFollowMessagesOnDomReplace==='function')
? _shouldFollowMessagesOnDomReplace()
: (typeof _isMessagePaneNearBottom==='function'&&_isMessagePaneNearBottom(1200)));
const _settledStreamId=isActiveSession?(S.activeStreamId||(d&&d.stream_id)||''):'';
if(isActiveSession){
S.activeStreamId=null;
}
let lastAsst=null;
if(isActiveSession){
// Capture previous session totals BEFORE overwriting S.session with the new
// cumulative values from the done event. prevIn/prevOut are the totals as of
// the start of this turn; curIn/curOut are the full post-turn totals — the
// delta is the per-turn usage for #1159.
const _prevIn=(S.session&&S.session.input_tokens)||0;
const _prevOut=(S.session&&S.session.output_tokens)||0;
const _prevCost=(S.session&&S.session.estimated_cost)||0;
const _prevCacheRead=(S.session&&S.session.cache_read_tokens)||0;
const _prevCacheWrite=(S.session&&S.session.cache_write_tokens)||0;
S.session=d.session;S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(d.session);if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
// #4720: reset _oldestIdx (full-load symmetry; keeps the #4613 anchor aligned).
if(typeof _oldestIdx!=='undefined')_oldestIdx=d.session._messages_offset||0;
S.messages=_filterRecoveryControlMessages(S.messages || []);
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
if(typeof clearVisibleMessageRowCache==='function') clearVisibleMessageRowCache();
if(S.session&&S.session.session_id){
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
}
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
if(
window._compressionUi&&window._compressionUi.automatic&&
window._compressionUi.sessionId===activeSid&&
d.session&&d.session.session_id
){
if(window._compressionUi.phase==='running'){
// Turn completed (done event) but the compression UI is still in
// 'running' phase - the 'compressed' SSE event was lost or delayed.
// Clear the stale running state instead of leaving it active,
// which would surface a phantom "Compressing context" barrier.
// This covers both A->B (session rotation) and A->A (no rotation)
// since in both cases a running phase at done-time means the
// compressed event never arrived.
if(typeof clearCompressionUi==='function') clearCompressionUi();
else window._compressionUi=null;
} else {
window._compressionUi={...window._compressionUi, sessionId:d.session.session_id};
}
}
// Find the last assistant message once for both reasoning persistence and timestamp
lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
// Persist reasoning trace for Worklog Thinking Cards; normal transcript
// rendering keeps provider reasoning out of the final answer.
if(reasoningText&&lastAsst&&!lastAsst.reasoning) lastAsst.reasoning=reasoningText;
// Strip any inline <think> blocks still embedded in the server-side
// content (M3 OpenAI-compat doesn't separate reasoning). Move them
// to m.reasoning so the persisted session stays compact and the
// thinking card has a proper source field on reload.
if(lastAsst && typeof lastAsst.content === 'string' && lastAsst.content){
const split=_splitThinkFromContent(lastAsst.content, lastAsst.reasoning);
if(split.content!==lastAsst.content){
lastAsst.content=split.content;
if(split.reasoning) lastAsst.reasoning=split.reasoning;
}
}
// Stamp _ts on the last assistant message if it has no timestamp
if(lastAsst&&!lastAsst._ts&&!lastAsst.timestamp) lastAsst._ts=Date.now()/1000;
if(d.usage){
const _doneUsageFallback={...(S.lastUsage||{})};
if(S.session){
for(const _usageField of ['context_length','threshold_tokens','last_prompt_tokens','post_compression_context_tokens_estimate']){
if(_doneUsageFallback[_usageField]==null&&S.session[_usageField]!=null){
_doneUsageFallback[_usageField]=S.session[_usageField];
}
}
}
S.lastUsage=typeof _mergeUsageForCtxIndicator==='function'
? _mergeUsageForCtxIndicator(d.usage,_doneUsageFallback)
: {..._doneUsageFallback,...d.usage};
_syncCtxIndicator(S.lastUsage);
// #503 — compute per-turn cost delta and attach to last assistant message
if(lastAsst){
const prevIn=_prevIn;
const prevOut=_prevOut;
const prevCost=_prevCost;
const curIn=d.usage.input_tokens||0;
const curOut=d.usage.output_tokens||0;
const curCost=d.usage.estimated_cost||0;
const curCacheRead=d.usage.cache_read_tokens||0;
const curCacheWrite=d.usage.cache_write_tokens||0;
// Only set delta if values actually increased (skip no-op turns)
if(curIn>prevIn||curOut>prevOut||curCacheRead>_prevCacheRead||curCacheWrite>_prevCacheWrite){
lastAsst._turnUsage={
input_tokens:Math.max(0,curIn-prevIn),
output_tokens:Math.max(0,curOut-prevOut),
estimated_cost:Math.max(0,curCost-prevCost),
cache_read_tokens:Math.max(0,curCacheRead-_prevCacheRead),
cache_write_tokens:Math.max(0,curCacheWrite-_prevCacheWrite),
cache_hit_percent:d.usage.turn_cache_hit_percent,
};
}
if(typeof d.usage.duration_seconds==='number'){
lastAsst._turnDuration=d.usage.duration_seconds;
}
if(typeof d.usage.tps==='number'&&d.usage.tps>0){
lastAsst._turnTps=d.usage.tps;
}
if(d.usage.gateway_routing){
lastAsst._gatewayRouting=d.usage.gateway_routing;
if(S.session)S.session.gateway_routing=d.usage.gateway_routing;
if(S.session&&Array.isArray(S.session.gateway_routing_history))S.session.gateway_routing_history.push(d.usage.gateway_routing);
else if(S.session)S.session.gateway_routing_history=[d.usage.gateway_routing];
}
}
}
_attachProjectedAnchorSceneToLastAssistant(S.messages);
const hasMessageToolMetadata=S.messages.some(m=>{
if(!m||m.role!=='assistant') return false;
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
return hasTc||hasPartialTc||hasTu;
});
if(!hasMessageToolMetadata&&d.session.tool_calls&&d.session.tool_calls.length){
S.toolCalls=d.session.tool_calls.map(tc=>tc);
S.toolCalls=_mergeSettledToolCallsWithLiveMetadata(d.session.tool_calls);
} else {
if(hasMessageToolMetadata) S._settledLiveToolMetadata=S.toolCalls.map(tc=>({...tc,done:true}));
S.toolCalls=hasMessageToolMetadata?[]:S.toolCalls.map(tc=>({...tc,done:true}));
}
if(typeof projectSessionArtifactsForOwner==='function') projectSessionArtifactsForOwner(completedSid);
if(uploaded.length){
const lastUser=[...S.messages].reverse().find(m=>m.role==='user');
if(lastUser)lastUser.attachments=uploaded;
}
if(_latestGoalStatus&&_latestGoalStatus.message){
S.messages.push({
role:'assistant',
content:String(_latestGoalStatus.message),
_ts:Date.now()/1000,
_goalStatus:true,
_transient:true,
});
}
// Keep the rendered live Worklog in place until the settled transcript swaps
// it for the settled anchor scene. Removing it first exposes an empty
// transcript frame on large sessions.
clearLiveToolCards({preserveDom:true});
S.busy=false;
// No-reply guard (#373): if agent returned nothing, show inline error
if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});}
if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
if(isSessionViewed) _markSessionViewed(completedSid, completedMessageCount);
// Cooldown: prevent refreshActiveSessionIfExternallyUpdated from
// force-reloading immediately after "done" — the event already
// delivered the final messages and tool calls.
if(typeof window!=='undefined') window._streamJustFinished=true;
setTimeout(()=>{ if(typeof window!=='undefined') window._streamJustFinished=false; }, 5000);
// Expand render window to cover all messages so the done render
// doesn't hide Activity behind a tiny window (winSize=50).
if(typeof _messageRenderableMessageCount==='function'&&typeof _messageRenderWindowSize!=='undefined'){
_messageRenderWindowSize=Math.max(typeof _currentMessageRenderWindowSize==='function'?_currentMessageRenderWindowSize():50, _messageRenderableMessageCount());
}
// #4650 review: the agent turn that just completed may have changed
// server-side reasoning config (e.g. a `/reasoning <level>` slash
// command writes agent.reasoning_effort) WITHOUT changing the model/
// provider cache key. Invalidate the reasoning-chip cache once at the
// turn boundary so the following syncTopbar() refetches the authoritative
// effort exactly once (not per-token — the storm short-circuit is intact).
if(typeof _lastReasoningFetchKey!=='undefined') _lastReasoningFetchKey=null;
// Arm one-shot keep-open so the JUST-settled worklog stays open on the
// settle render (height-stable swap, no shrink jump). Disarm, then run a
// scroll-PRESERVING collapse pass for BOTH pin states so the worklog
// returns to its copied live/user disclosure state (a pinned follower's
// scrollToBottom() only settles scroll, it does NOT re-render, so without
// this pass the forced-open DOM would persist for them). This unarmed
// render also populates the cache with the correctly-collapsed DOM, and
// the same-frame JS restore absorbs the collapse so there is no jump.
// (#5260 gate-cert: keep-open must be transient + uncached for everyone.)
// #6385: capture the scroll snapshot from the LIVE DOM before arming
// keep-open, so the collapse render below anchors to the content the
// reader was actually viewing — not to a stale intermediate state where
// the worklog was temporarily expanded.
const _doneLiveScrollSnapshot=typeof _captureMessageScrollSnapshot==='function'
? _captureMessageScrollSnapshot()
: null;
if(typeof _armKeepSettledWorklogOpen==='function') _armKeepSettledWorklogOpen(_settledStreamId);
syncTopbar();renderMessages({preserveScroll:true});
if(typeof _disarmKeepSettledWorklogOpen==='function') _disarmKeepSettledWorklogOpen();
const _collapsedInPlace=typeof _collapseJustSettledWorklogInPlace==='function'
&& _collapseJustSettledWorklogInPlace(_settledStreamId);
if(!_collapsedInPlace&&typeof _renderMessagesWithScrollSnapshot==='function'){
_renderMessagesWithScrollSnapshot({_prescrollSnapshot:_doneLiveScrollSnapshot});
}else if(!_collapsedInPlace){
renderMessages({preserveScroll:true});
}else if(_doneLiveScrollSnapshot&&typeof _restoreMessageScrollSnapshotSameFrame==='function'){
_restoreMessageScrollSnapshotSameFrame(_doneLiveScrollSnapshot);
}
if(typeof _restoreMessageRenderWindowAfterSettledRender==='function') _restoreMessageRenderWindowAfterSettledRender();
if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
if(typeof noteWorkspaceMutationsFromToolCalls==='function') noteWorkspaceMutationsFromToolCalls(S.toolCalls);
loadDir('.', { preservePreview: true });
// TTS auto-read: speak the last assistant response if enabled (#499)
if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
}
if(!lastAsst&&d.session&&Array.isArray(d.session.messages)){
lastAsst=[...d.session.messages].reverse().find(m=>m&&m.role==='assistant')||null;
}
if(isActiveSession&&_pendingGoalContinuation&&typeof queueSessionMessage==='function'){
const _goalNext=_pendingGoalContinuation;
_pendingGoalContinuation=null;
queueSessionMessage(_goalNext.sid,{
text:_goalNext.text,
files:[],
model:_goalNext.model,
model_provider:_goalNext.model_provider,
profile:_goalNext.profile,
});
if(typeof updateQueueBadge==='function')updateQueueBadge(_goalNext.sid);
}
if(isActiveSession) _queueDrainSid=activeSid;
renderSessionList();
_setActivePaneIdleIfOwner();
_dispatchExtensionTurnLifecycle('turn:complete',activeSid,streamId,{
status:d.status||'completed',
endedAt:Date.now()/1000,
});
playNotificationSound();
// #4416: notify if the tab was hidden at ANY point during this stream
// (not just at done-receive time, which a throttled background-tab SSE
// delivers late — after the user returns and document.hidden is false).
// If the user watched the whole stream, _wasEverHidden stays false and
// the notification is suppressed (matches Slack/Discord/Gmail/Claude).
const _wasEverBackgrounded=_shouldForceCompletionNotification(activeSid, streamId);
const _completionPreview=_completionNotificationPreviewText(lastAsst,{
sessionId:completedSid,
liveDisplayText:typeof _streamDisplay==='function'?_streamDisplay():assistantText,
});
sendBrowserNotification('Response complete',_completionPreview||'Task finished',{forceHidden:_wasEverBackgrounded,sid:activeSid});
};
if(_shouldUseLiveProseFade()&&assistantBody){
_cancelAnimationFramePendingStreamRender();
_drainStreamFadeBeforeDone(_finishDone);
return;
}
_finishDone();
});
source.addEventListener('stream_end',async e=>{
if(_streamFinalized){
_closeSource(source);
return;
}
_clearStreamEndRecovery();
if(_bailOutOfTerminalEventsFromStaleStream(source)) return;
try{
const d=JSON.parse(e.data||'{}');
if((d.session_id||activeSid)!==activeSid) return;
}catch(_){}
if(S.activeStreamId===streamId && _liveStreamEndScenePresent()){
_scheduleStreamEndRecovery(source);
return;
}
// Some replay/journal paths can deliver stream_end without a preceding
// done event. In that case closing the EventSource is not enough: the
// live DOM/inflight state remains projected and can duplicate Thinking or
// assistant content until a later session switch. Settle from the persisted
// session before closing so the pane converges on canonical state.
const status=await _restoreSettledSession(source,{status:true});
if(status==='restored'){
return;
}
if(status==='active'&&S.activeStreamId===streamId){
_scheduleStreamEndRecovery(source,200);
return;
}
_finalizeStreamEndFallback(source);
});
source.addEventListener('pending_steer_leftover',e=>{
// The agent finished its turn with steer text still stashed (no
// tool-result boundary fired). Match the CLI's leftover-delivery
// behaviour: queue the leftover text as a next-turn user message
// so the existing drain in setBusy(false) ships it.
try{
const d=JSON.parse(e.data||'{}');
const sid=d.session_id||activeSid;
const txt=String(d.text||'').trim();
if(!txt||sid!==activeSid) return;
_applyToAnchor('pending_steer_leftover',d,e);
if(typeof queueSessionMessage==='function'){
const _modelState=_chatPayloadModelState();
queueSessionMessage(sid,{
text:txt,files:[],
model:_modelState.model,
model_provider:_modelState.model_provider,
profile:S.activeProfile||'default',
});
if(typeof updateQueueBadge==='function') updateQueueBadge(sid);
showToast(t('steer_leftover_queued'),3000);
}
}catch(_){}
});
source.addEventListener('compressing',e=>{
// Context auto-compression is starting. Surface the same calm running
// compression card as manual /compress while the summarizer LLM call runs.
if(!S.session||S.session.session_id!==activeSid) return;
let d={};
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
if(d.session_id&&d.session_id!==activeSid) return;
_applyToAnchor('compressing',d,e);
const state={
sessionId:activeSid,
phase:'running',
automatic:true,
message:'Compressing context',
startedAt:Date.now()/1000,
};
if(typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state)){
// Keep automatic compression inside the active Worklog. Calling
// renderMessages() here rebuilds from the still-empty persisted
// transcript during active streams and can erase already replayed tools.
if(typeof clearCompressionUi==='function') clearCompressionUi();
else window._compressionUi=null;
snapshotLiveTurn();
return;
}
if(typeof setCompressionUi==='function'){
setCompressionUi(state);
}
snapshotLiveTurn();
});
source.addEventListener('compressed',e=>{
// Context was auto-compressed during this turn. Keep the live timeline
// honest by transitioning the running divider into a completed divider;
// final settlement removes live-only compression rows from the Worklog.
if(!S.session) return;
const currentSid=S.session.session_id;
let d={};
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
const eventSid=d.old_session_id||d.session_id||activeSid;
const continuationSid=d.new_session_id||d.continuation_session_id||'';
const eventMatchesCurrent=!!(currentSid&&(eventSid===currentSid||d.new_session_id===currentSid||d.continuation_session_id===currentSid));
if(!eventMatchesCurrent) return;
_applyToAnchor('compressed',d,e);
const displaySid=currentSid;
if(d.usage&&typeof _syncCtxIndicator==='function'){
S.lastUsage=typeof _mergeUsageForCtxIndicator==='function'
? _mergeUsageForCtxIndicator(d.usage,S.lastUsage||{})
: {...(S.lastUsage||{}),...d.usage};
_syncCtxIndicator(S.lastUsage);
}
if(typeof appendLiveCompressionCard==='function'){
appendLiveCompressionCard({
sessionId:displaySid,
phase:'done',
automatic:true,
message:'Context auto-compressed',
continuationSessionId:continuationSid,
});
}
if(typeof clearCompressionUi==='function') clearCompressionUi();
else window._compressionUi=null;
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
if(!S.busy&&typeof renderMessages==='function') renderMessages();
});
source.addEventListener('metering',e=>{
try{
const d=JSON.parse(e.data||'{}');
if((d.session_id||activeSid)!==activeSid) return;
if(d.usage&&typeof _syncCtxIndicator==='function'){
if(S.session&&S.session.session_id===activeSid){
S.lastUsage=typeof _mergeUsageForCtxIndicator==='function'
? _mergeUsageForCtxIndicator(d.usage,S.lastUsage||{})
: {...(S.lastUsage||{}),...d.usage};
_syncCtxIndicator(S.lastUsage);
}
}
if(d.estimated===true||d.tps_available!==true||typeof d.tps!=='number'||d.tps<=0){
if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(null);
return;
}
if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(d.tps);
}catch(_){}
});
source.addEventListener('apperror',e=>{
if(_bailOutOfTerminalEventsFromStaleStream(source)) return;
_clearStreamEndRecovery();
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_cancelThrottledSnapshotTimer();
_clearAnchorProseIncrementalNode();
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
// This is distinct from the SSE network 'error' event below.
try{if(source&&source.readyState!==2)source.close();}catch(_){ }
_clearOwnerInflightState();
_clearStreamHidden(activeSid, streamId); // #4416: terminal path, drop hidden tracker
_clearStreamNotificationBackground(activeSid, streamId);
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
let d={};
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
const _extensionErrorType=(d.type==='cancelled'||d.type==='interrupted')?'turn:cancel':'turn:error';
const currentSid=S.session&&S.session.session_id;
const eventSid=d.old_session_id||d.session_id||'';
const continuationSid=(d.session&&d.session.session_id)||d.new_session_id||d.continuation_session_id||'';
const eventMatchesCurrent=!!(currentSid&&(eventSid===currentSid||continuationSid===currentSid));
if(eventMatchesCurrent){
_flushReasoningToAnchor();
_applyToAnchor('apperror',{
type:d.type||'error',
status:d.status||d.type||'error',
message:d.message||'',
hint:d.hint||'',
details:d.details||'',
session_id:d.session_id||eventSid||activeSid,
old_session_id:d.old_session_id||null,
new_session_id:d.new_session_id||d.continuation_session_id||null,
},e);
}
if(S.session&&eventMatchesCurrent){
S.activeStreamId=null;
_scheduleAnchorRegistryCleanup();
clearLiveToolCards();if(!assistantText)removeThinking();
let isRecoveryControlMessage=false;
let _anchorRetryTarget=null;
let _anchorRetryIndex=-1;
try{
const isRateLimit=d.type==='rate_limit';
const isQuotaExhausted=d.type==='quota_exhausted';
const isAuthMismatch=d.type==='auth_mismatch';
const isGatewayAuthError=d.type==='gateway_auth_error';
const isModelNotFound=d.type==='model_not_found';
const isCancelled=d.type==='cancelled';
const isInterrupted=d.type==='interrupted';
const isCompressionExhausted=d.type==='compression_exhausted';
const isToolLimitReached=d.type==='tool_limit_reached';
isRecoveryControlMessage=isInterrupted && (d.recovery_control===true || _streamRecoveryControlMessageText(d.message));
const isNoResponse=d.type==='no_response'||d.type==='silent_failure';
const label=isCancelled?'Task cancelled':isInterrupted?'Response interrupted':isCompressionExhausted?'Context compression exhausted':isToolLimitReached?'Tool iteration limit reached':isQuotaExhausted?'Out of credits':isRateLimit?'Rate limit reached':isGatewayAuthError?(typeof t==='function'?t('gateway_auth_label'):'Gateway authentication failed'):isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isModelNotFound?(typeof t==='function'?t('model_not_found_label'):'Model not found'):isNoResponse?'No response from provider':'Error';
const hint=d.hint?`\n\n*${d.hint}*`:'';
const details=d.details?String(d.details).replace(/```/g,'`\u200b``'):'';
const detailsLabel=isCancelled?'Cancellation details':isInterrupted?'Interruption details':isToolLimitReached?'Terminal state details':undefined;
window._compressionUi=null;
if(typeof clearCompressionUi==='function') clearCompressionUi();
if(isRecoveryControlMessage){
if(typeof showToast==='function') showToast('Stream recovery signal received. Restoring transcript...',3500,'error');
} else if(d.session&&typeof d.session==='object'){
S.session=d.session;
const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role);
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(d.session);
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
if(S.session&&S.session.session_id){
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
}
} else {
const recovery=(d.compression_recovery&&typeof d.compression_recovery==='object')?d.compression_recovery:null;
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`,provider_details:details,provider_details_label:detailsLabel,_compressionRecovery:recovery||undefined});
_attachProjectedAnchorSceneToLastAssistant(S.messages);
}
if(!isRecoveryControlMessage){
_anchorRetryTarget=[...S.messages].reverse().find(m=>m&&m.role==='assistant')||null;
_anchorRetryIndex=_anchorRetryTarget?S.messages.indexOf(_anchorRetryTarget):-1;
}
}catch(_){
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
_attachProjectedAnchorSceneToLastAssistant(S.messages);
}
if(_anchorRetryTarget&&_anchorRetryIndex>=0){
const _retryTarget=_anchorRetryTarget;
const _retryIndex=_anchorRetryIndex;
const _retryStreamId=streamId;
const _retryRegistry=_anchorRegistry;
const _retryOwnerKey=_settledAnchorRetryOwnerKey(S.messages,_retryIndex,_retryStreamId);
// Retry only for the exact terminal assistant and registry generation.
// A refresh replacement must prove the same full turn and tool owner.
setTimeout(()=>{
_retrySettledAnchorScene(_retryTarget,_retryIndex,_retryStreamId,_retryRegistry,_retryOwnerKey);
},0);
}
if(isRecoveryControlMessage){
(async()=>{
if(await _restoreSettledSession(source, {preserveVisibleOnShorterTerminalSnapshot:true})) return;
if(S.session&&S.session.session_id===activeSid){
S.messages=_filterRecoveryControlMessages(S.messages||[]);
_markSessionViewed(activeSid, S.messages.length);
renderMessages({preserveScroll:true});
}
})();
} else {
_markSessionViewed((S.session&&S.session.session_id)||activeSid, S.messages.length);
renderMessages({preserveScroll:true});
}
}else if(typeof trackBackgroundError==='function'){
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
trackBackgroundError(activeSid,_errTitle,d.message||'Error');
}
_setActivePaneIdleIfOwner();
renderSessionList(); // clear streaming indicator immediately on apperror
_dispatchExtensionTurnLifecycle(_extensionErrorType,activeSid,streamId,{
status:d.status||d.type||(_extensionErrorType==='turn:cancel'?'cancelled':'error'),
endedAt:Date.now()/1000,
});
});
source.addEventListener('warning',e=>{
// Non-fatal warning from server (e.g. fallback activated, retrying)
if(!S.session||S.session.session_id!==activeSid) return;
try{
const d=JSON.parse(e.data);
if(d.type==='approval_gateway_unsupported'){
if(typeof showToast==='function') showToast(typeof t==='function'?t('approval_gateway_unsupported_label'):'Approvals not supported',4000,'warning');
return;
}
if(d.type==='approval_gateway_offline'){
if(typeof showToast==='function') showToast(d.message||'Gateway offline',4000,'warning');
return;
}
// Show as a small inline notice, not a full error
setComposerStatus(`${d.message||'Warning'}`,d.type==='fallback'?4000:undefined);
}catch(_){}
});
source.addEventListener('error',async e=>{
if(_bailOutOfTerminalEventsFromStaleStream(source) && !_streamFinalized){
return;
}
if(_terminalStateReached || _streamFinalized){
_closeSource(source);
return;
}
// #3885: if a stream_end recovery is in flight, don't start a competing
// reconnect — recovery polls server state and owns the terminal decision
// (else its exhaustion could mute a freshly reconnected stream). Opus stage-LK.
if(_pendingStreamEndRecovery){
_closeSource(source);
return;
}
if(typeof recordClientSSEError==='function') recordClientSSEError('chat-response',{ready_state:source?source.readyState:null,session_id:activeSid,stream_id:streamId,reason:'chat EventSource.onerror'});
try{if(source&&source.readyState!==2)source.close();}catch(_){ }
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
_closeSource(source);
// If the user has switched to a different session, don't attempt to
// reconnect — the old stream's EventSource was closed intentionally
// during session switch and reconnecting would leak a background stream.
if(!_isSessionCurrentPane(activeSid)) return;
if(_terminalStateReached || _streamFinalized){
return;
}
// Attempt several reconnect/replay probes before declaring the turn lost.
// A short-lived SSE error can arrive while the worker is still running or
// while the run-journal replay file is just becoming visible. The old
// single 1.5s probe could fall through to _handleStreamError(), clearing
// S.activeStreamId/INFLIGHT and rendering a connection-interrupted marker
// even though the backend was still producing tokens; the settled response
// then reappeared later from sidecar/replay. Keep the live DOM/state intact
// during this retry window and only surface an error after all probes fail.
if(!_reconnectAttempted && streamId){
_reconnectAttempted=true;
const _retryDelays=[1500,3000,5000,8000,12000,20000];
setComposerStatus(`Reconnecting… (1/${_retryDelays.length})`);
const _probeReconnect=async(attempt=0)=>{
if(_terminalStateReached || _streamFinalized) return;
if(!_isSessionCurrentPane(activeSid)) return;
try{
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
if(st&&st.active){
setComposerStatus('Reconnected',1000);
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}${_runJournalReplayParams()}`,document.baseURI||location.href).href,{withCredentials:true}));
return;
}
if(st&&st.replay_available){
setComposerStatus('Restoring stream…');
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}${_runJournalReplayParams()}`,document.baseURI||location.href).href,{withCredentials:true}));
return;
}
}catch(_){
if(_deferStreamErrorIfOffline()) return;
}
if(await _restoreSettledSession(source, {preserveVisibleOnShorterTerminalSnapshot:true})) return;
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
const nextDelay=_retryDelays[attempt+1];
if(nextDelay){
setComposerStatus(`Reconnecting… (${attempt+2}/${_retryDelays.length})`);
setTimeout(()=>{void _probeReconnect(attempt+1);}, nextDelay);
return;
}
// Last-ditch: the stream may have finished while we were retrying.
// _restoreSettledSession polls the full session API (not just stream
// status) and can recover a completed response without an error banner.
// This is especially important on iOS where Tailscale reconnects can
// take longer than the retry window.
setComposerStatus('Restoring session…');
let _restoreTimedOut=false;
const _restoreTimer=setTimeout(()=>{
// If _restoreSettledSession hangs (flaky Tailscale), don't leave
// the UI stuck on "Restoring session…" forever. Fall through to
// _handleStreamError after 8s.
_restoreTimedOut=true;
if(!_terminalStateReached&&!_streamFinalized){
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source);
}
},8000);
try{
if(await _restoreSettledSession(source, {preserveVisibleOnShorterTerminalSnapshot:true})){
if(_restoreTimedOut) return; // timer already fired _handleStreamError
clearTimeout(_restoreTimer);
return;
}
}catch(_){
// _restoreSettledSession threw. If the timer already fired,
// _handleStreamError was called there; we return below.
// Otherwise the code below cancels the timer and calls it directly.
}
if(_restoreTimedOut) return; // timer already fired _handleStreamError
clearTimeout(_restoreTimer);
if(_terminalStateReached||_streamFinalized) return;
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source);
};
setTimeout(()=>{void _probeReconnect(0);},_retryDelays[0]);
return;
}
if(await _restoreSettledSession(source, {preserveVisibleOnShorterTerminalSnapshot:true})) return;
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source);
});
source.addEventListener('cancel',e=>{
if(_bailOutOfTerminalEventsFromStaleStream(source)) return;
_clearStreamEndRecovery();
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_cancelThrottledSnapshotTimer();
_clearAnchorProseIncrementalNode();
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
try{if(source&&source.readyState!==2)source.close();}catch(_){ }
_clearOwnerInflightState();
_clearStreamHidden(activeSid, streamId); // #4416: terminal path, drop hidden tracker
_clearStreamNotificationBackground(activeSid, streamId);
_clearApprovalForOwner();
_clearClarifyForOwner('cancelled');
let _cancelData={};
try{ _cancelData=JSON.parse(e.data||'{}')||{}; }catch(_){ _cancelData={}; }
_flushReasoningToAnchor();
_applyToAnchor('cancel',{
status:_cancelData.status||_cancelData.type||'cancelled',
message:_cancelData.message||'',
session_id:_cancelData.session_id||activeSid,
},e);
_scheduleAnchorRegistryCleanup();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
}
const _applyCancelSessionPayload=(sessionPayload)=>{
if(!sessionPayload||typeof sessionPayload!=='object'||!S.session||S.session.session_id!==activeSid) return false;
// Belt-and-suspenders: the embedded cancel snapshot must be for THIS session.
// The GET path guarantees it via the URL; the embedded path via the stream→session
// binding — but reject a mismatched id so a stray payload can't overwrite the view.
if(sessionPayload.session_id&&sessionPayload.session_id!==activeSid) return false;
// Capture follow-intent BEFORE replacing S.messages: a reader who was
// following the live stream when it got cancelled/reconnected must land at
// the bottom (where the cancellation notice renders), not be stranded at a
// stale mid-stream scrollTop by preserveScroll's restore path. Same
// jump-on-recovery class as the Connection-interrupted path below.
const _wasFollowingAtCancel=((typeof _isMessagePaneNearBottom==='function')
? _isMessagePaneNearBottom(1200)
: true)
&& !((typeof _isMessageReaderUnpinned==='function')
? _isMessageReaderUnpinned()
: (typeof _messageUserUnpinned!=='undefined' && _messageUserUnpinned));
S.session=sessionPayload;
const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(sessionPayload);
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
clearLiveToolCards();if(!assistantText)removeThinking();
_markSessionViewed(activeSid, sessionPayload.message_count ?? S.messages.length);
renderMessages({preserveScroll:true});
if(_wasFollowingAtCancel && typeof scrollToBottom==='function') scrollToBottom();
return true;
};
// Prefer the canonical session snapshot embedded in the terminal cancel event.
// It includes _partial reasoning/tool rows captured by cancel_stream(), avoiding
// a second GET race where the visible cancelled work briefly collapses to only
// the fallback "Task cancelled" marker (#4076).
const _cancelSessionPayload=_cancelData&&typeof _cancelData.session==='object'?_cancelData.session:null;
renderSessionList();
_setActivePaneIdleIfOwner();
(async()=>{
try{
if(_applyCancelSessionPayload(_cancelSessionPayload)) return;
// Fetch latest session from server to get accurate message list (includes cancel status)
// This ensures messages stay in sync with server, fixing race condition where local
// "*Task cancelled.*" message gets lost when done event overwrites S.messages
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
if(data&&data.session) _applyCancelSessionPayload(data.session);
}catch(_){
// Fallback to local cancel message if API fails
if(S.session&&S.session.session_id===activeSid){
const _wasFollowingAtCancelFb=((typeof _isMessagePaneNearBottom==='function')
? _isMessagePaneNearBottom(1200)
: true)
&& !((typeof _isMessageReaderUnpinned==='function')
? _isMessageReaderUnpinned()
: (typeof _messageUserUnpinned!=='undefined' && _messageUserUnpinned));
clearLiveToolCards();if(!assistantText)removeThinking();
const cancelAgentName=(assistantDisplayName()+'').trim()||'Hermes';
S.messages.push({role:'assistant',content:`**Task cancelled:** Task cancelled.\n\n*The run was cancelled by the user before ${cancelAgentName} finished. No provider failure occurred.*`,provider_details:'Task cancelled.',provider_details_label:'Cancellation details',_error:true});
_attachProjectedAnchorSceneToLastAssistant(S.messages);
renderMessages({preserveScroll:true});
if(_wasFollowingAtCancelFb && typeof scrollToBottom==='function') scrollToBottom();
_markSessionViewed(activeSid, S.messages.length);
}
}finally{
_dispatchExtensionTurnLifecycle('turn:cancel',activeSid,streamId,{
status:_cancelData.status||_cancelData.type||'cancelled',
endedAt:Date.now()/1000,
});
}
})();
});
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','todo_state','approval','clarify','state_saved','title','title_status','context_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
source.addEventListener(_runJournalEventName,_rememberRunJournalCursor);
}
}
// #3018: per-turn ephemeral fields are computed client-side in _finishDone
// and attached to message objects (S.messages). When a server refresh
// (loadSession, _restoreSettledSession, external active-session poll,
// SSE error recovery) replaces S.messages with fresh server data, those
// fields are dropped and the usage badge / duration / gateway routing
// pill flashes-then-disappears. Carry them forward by matching messages
// on (role, timestamp, content prefix) — the same identity the renderer
// already uses for stable keys.
function _messageIdentityKey(m){
if(!m||!m.role) return '';
const ts=m._ts||m.timestamp||'';
let body='';
if(typeof m.content==='string') body=m.content;
else if(Array.isArray(m.content)){
try{ body=m.content.map(p=>(p&&typeof p==='object')?(p.text||p.input_text||'')||'':String(p||'')).join('').slice(0,160); }catch(_){ body=''; }
}
return `${m.role}|${ts}|${body.slice(0,160)}`;
}
const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene'];
function _isHistoricalAnchorActivityScene(scene){
if(!scene||typeof scene!=='object') return false;
const identity=scene.identity&&typeof scene.identity==='object'?scene.identity:null;
const turnId=identity&&typeof identity.turn_id==='string'?identity.turn_id:'';
return turnId.indexOf('historical:')===0;
}
function _carryForwardEphemeralTurnFields(prevMessages, nextMessages){
if(!Array.isArray(prevMessages)||!Array.isArray(nextMessages)) return nextMessages;
if(!prevMessages.length||!nextMessages.length) return nextMessages;
const prevIdx=new Map();
for(const pm of prevMessages){
const k=_messageIdentityKey(pm); if(!k) continue;
// If duplicate keys, prefer the latest occurrence (it carries the
// most-recently-attached ephemeral state).
prevIdx.set(k,pm);
}
for(const nm of nextMessages){
const k=_messageIdentityKey(nm); if(!k) continue;
const pm=prevIdx.get(k); if(!pm) continue;
for(const f of _EPHEMERAL_TURN_FIELDS){
if(f==='_anchor_activity_scene'&&_isHistoricalAnchorActivityScene(pm[f])) continue;
if(pm[f]!=null && nm[f]==null) nm[f]=pm[f];
}
}
return nextMessages;
}
if(typeof window!=='undefined'){
window._carryForwardEphemeralTurnFields=_carryForwardEphemeralTurnFields;
}
async function _restoreSettledSession(source, options=null){
const returnStatus=!!(options&&options.status);
const preserveVisibleOnShorterTerminalSnapshot=!!(options&&options.preserveVisibleOnShorterTerminalSnapshot);
if(_isActiveSession() && S.activeStreamId!==streamId){
_closeSource(source);
return returnStatus?'stale':false;
}
try{
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
// Opus #2852 race-fix: if a late `done` event ran the finalize path while
// we were awaiting the network roundtrip, bail out — done already settled.
if(_streamFinalized) return returnStatus?'restored':true;
const session=data&&data.session;
if(!session) return returnStatus?'missing':false;
if(session.active_stream_id||session.pending_user_message) return returnStatus?'active':false;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_cancelThrottledSnapshotTimer();
_clearAnchorProseIncrementalNode();
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup();
_closeSource(source);
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
const isSessionViewed=_isSessionActivelyViewed(activeSid);
const completedSid=session.session_id||activeSid;
if(!isSessionViewed && typeof _markSessionCompletionUnread==='function'){
_markSessionCompletionUnread(completedSid, session.message_count);
}
const isActiveSession=_isSessionCurrentPane(activeSid);
if(isActiveSession){
S.activeStreamId=null;
clearLiveToolCards();if(!assistantText)removeThinking();
S.session=session;
const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role);
const _currentMessages=Array.isArray(S.messages)?S.messages:[];
const _currentVisibleMessages=_filterRecoveryControlMessages(_currentMessages || []);
const _stagedMessages=_carryForwardEphemeralTurnFields(_currentMessages, _nextMsgs3018);
const _currentVisibleEndsWithTerminalMarker=(
_currentVisibleMessages.length>0 &&
_isTerminalStreamErrorMarkerMessage(_currentVisibleMessages[_currentVisibleMessages.length-1])
);
const _stagedMatchesCurrentPrefix=(
_stagedMessages.length>0 &&
_stagedMessages.length<_currentVisibleMessages.length &&
_currentVisibleEndsWithTerminalMarker &&
_stagedMessages.every((message, idx)=>{
const stagedKey=_messageIdentityKey(message);
const currentKey=_messageIdentityKey(_currentVisibleMessages[idx]);
return !!stagedKey && stagedKey===currentKey;
})
);
const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&_stagedMatchesCurrentPrefix;
const _resolvedMessages=_preserveCurrentTranscript
? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMessages.length)]
: _stagedMessages;
S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);
_attachProjectedAnchorSceneToLastAssistant(S.messages);
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
if(S.session&&S.session.session_id){
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
}
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session);
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
const hasMessageToolMetadata=S.messages.some(m=>{
if(!m||m.role!=='assistant') return false;
// Recognize both the standard `tool_calls` (used by completed assistant
// turns where the LLM emitted tool_call entries) and the WebUI-internal
// `_partial_tool_calls` (used on Stop/Cancel partial messages — see
// api/streaming.py cancel_stream).
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
return hasTc||hasPartialTc||hasTu;
});
if(!hasMessageToolMetadata&&session.tool_calls&&session.tool_calls.length){
S.toolCalls=_mergeSettledToolCallsWithLiveMetadata(session.tool_calls||[]);
}else{
if(hasMessageToolMetadata) S._settledLiveToolMetadata=S.toolCalls.map(tc=>({...tc,done:true}));
S.toolCalls=[];
}
if(isSessionViewed) _markSessionViewed(completedSid, session.message_count ?? S.messages.length);
// Expand render window so the settled render doesn't hide Activity.
if(typeof _messageRenderableMessageCount==='function'&&typeof _messageRenderWindowSize!=='undefined'){
_messageRenderWindowSize=Math.max(typeof _currentMessageRenderWindowSize==='function'?_currentMessageRenderWindowSize():50, _messageRenderableMessageCount());
}
syncTopbar();renderMessages({preserveScroll:true});
if(typeof _restoreMessageRenderWindowAfterSettledRender==='function') _restoreMessageRenderWindowAfterSettledRender();
if(typeof projectSessionArtifactsForOwner==='function') projectSessionArtifactsForOwner(completedSid);
}
if(_isActiveSession()) _queueDrainSid=activeSid;
renderSessionList();
_setActivePaneIdleIfOwner();
return returnStatus?'restored':true;
}catch(_){
return returnStatus?'error':false;
}
}
function _handleStreamError(source){
if(_isActiveSession() && S.activeStreamId!==streamId){
_closeSource(source);
return;
}
_clearStreamEndRecovery();
// Opus review Q1: mirror done/apperror/cancel finalization so any pending rAF
// cannot fire after renderMessages() has settled the DOM with the error message.
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_cancelThrottledSnapshotTimer();
_clearAnchorProseIncrementalNode();
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_closeSource(source);
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
// Capture the reader's follow-intent BEFORE mutating S.messages. If the SSE
// dropped while they were following the live stream (pinned / near the
// bottom), the disconnect-recovery render must land them at the bottom where
// the "Connection interrupted" notice appears — NOT restore a stale
// mid-stream scrollTop. preserveScroll's restore path keys on the pre-render
// snapshot's bottom-distance, which during a live stream can read large
// (content was still growing under a followed viewport), so it would yank a
// following reader up to a historical position on a process restart / SSE
// drop. This is the "restart/disconnect jump-back" report: the jump is the
// recovery render restoring an old position into a DOM whose height changed.
// Follow-intent must be STICKY-aware: a reader who manually scrolled up
// (sets _messageUserUnpinned, ui.js scroll listener) but stayed within
// 1200px of the bottom would read _isMessagePaneNearBottom(1200)===true,
// so a proximity-only check would re-follow them on recovery and clobber
// their position (maintainer-reproduced bounce). Require near-bottom AND
// not-unpinned. scrollToBottom() clears _messageUserUnpinned, so a genuine
// follower stays pinned; only a manually-unpinned reader is spared.
const _wasFollowingAtDisconnect=((typeof _isMessagePaneNearBottom==='function')
? _isMessagePaneNearBottom(1200)
: true)
&& !((typeof _isMessageReaderUnpinned==='function')
? _isMessageReaderUnpinned()
: (typeof _messageUserUnpinned!=='undefined' && _messageUserUnpinned));
_flushReasoningToAnchor();
_applyToAnchor('error',{
status:'connection_lost',
message:'The browser lost the live SSE connection before the response finished.',
session_id:activeSid,
},null);
clearLiveToolCards();if(!assistantText)removeThinking();
_ensureSingleTerminalStreamErrorMarker(S.messages);
_attachProjectedAnchorSceneToLastAssistant(S.messages);
renderMessages({preserveScroll:true});
// If they were following the stream, force the viewport to the bottom after
// the recovery render so they see the interruption notice in place instead
// of being thrown back into the transcript. Readers who had scrolled up to
// read history are left where they were (the near-bottom guard above is
// false for them).
if(_wasFollowingAtDisconnect && typeof scrollToBottom==='function') scrollToBottom();
_markSessionViewed(activeSid, S.messages.length);
}else{
if(typeof trackBackgroundError==='function'){
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
trackBackgroundError(activeSid,_errTitle,'Connection interrupted');
}
}
_setActivePaneIdleIfOwner();
_dispatchExtensionTurnLifecycle('turn:error',activeSid,streamId,{
status:'connection_lost',
endedAt:Date.now()/1000,
});
}
(async()=>{
// Reattach path can carry stale stream ids after server restart; preflight
// status avoids opening a dead SSE URL that will 404 in the console.
let replayOnly=false;
if(reconnecting){
try{
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
if(!st.active&&st.replay_available){
replayOnly=true;
}else if(!st.active){
_clearOwnerInflightState();
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
if(S.session&&S.session.session_id===activeSid){
// Follow-intent BEFORE removing live placeholders: a reader following
// the (now-dead) stream should stay pinned to the bottom as the
// thinking/tool placeholders are cleared, not be stranded mid-transcript
// by preserveScroll restoring a stale scrollTop after the height shrinks.
const _wasFollowingAtReconnectDead=((typeof _isMessagePaneNearBottom==='function')
? _isMessagePaneNearBottom(1200)
: true)
&& !((typeof _isMessageReaderUnpinned==='function')
? _isMessageReaderUnpinned()
: (typeof _messageUserUnpinned!=='undefined' && _messageUserUnpinned));
S.activeStreamId=null;
clearLiveToolCards();
removeThinking();
if(_isActiveSession()) _queueDrainSid=activeSid;
_setActivePaneIdleIfOwner();
renderMessages({preserveScroll:true});
if(_wasFollowingAtReconnectDead && typeof scrollToBottom==='function') scrollToBottom();
renderSessionList();
}
_scheduleAnchorRegistryCleanup(120000);
return;
}
}catch(_){}
}
const replayParams=(reconnecting||replayOnly)?_runJournalReplayParams():'';
_dispatchExtensionTurnLifecycle('turn:start',activeSid,streamId,{
startedAt:_extensionTurnStartedAt,
});
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}${replayParams}`,document.baseURI||location.href).href,{withCredentials:true}));
})();
}
function transcript(){
const lines=[`# Hermes session ${S.session?.session_id||''}`,``,
`Workspace: ${S.session?.workspace||''}`,`Model: ${S.session?.model||''}`,``];
for(const m of S.messages){
if(!m||m.role==='tool')continue;
let c=m.content||'';
if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('\n');
const ct=String(c).trim();
if(!ct&&!m.attachments?.length)continue;
const attach=m.attachments?.length?`\n\n_Files: ${m.attachments.join(', ')}_`:'';
lines.push(`## ${m.role}`,'',ct+attach,'');
}
return lines.join('\n');
}
let _composerAutoResizeRaf=0;
let _composerLastResizeValue='';
function autoResize(){
if(_composerAutoResizeRaf && typeof cancelAnimationFrame==='function'){
cancelAnimationFrame(_composerAutoResizeRaf);
_composerAutoResizeRaf=0;
}
const el=$('msg');
const _nextValue=String(el.value||'');
if(typeof CSS!=='undefined'&&typeof CSS.supports==='function'&&CSS.supports('field-sizing','content')){
if(el.style.height) el.style.height='';
_composerLastResizeValue=_nextValue;
updateSendBtn();
return;
}
const _isAppendOnly=_nextValue.length>_composerLastResizeValue.length&&_nextValue.startsWith(_composerLastResizeValue);
// An EMPTY composer has no content to measure, so clear any inline height and
// let the CSS `min-height` define the resting size. Measuring instead would
// read the PLACEHOLDER's scrollHeight — a long busy/compression hint wraps to
// two or three lines and would grow the empty composer (71px for the English
// busy hint, 97px for the French compression one) purely because of hint text.
// That made the empty height history-dependent on this path: 44px on a fresh
// send, but grown after any later resize while empty. The native
// `field-sizing` path above always holds the resting height, so clearing here
// keeps both paths on the same contract.
if(!_nextValue){
if(el.style.height) el.style.height='';
_composerLastResizeValue=_nextValue;
updateSendBtn();
return;
}
const _fitsCurrentHeight=el.scrollHeight<=el.offsetHeight;
// Only a direct append at the natural one-row height can skip the height
// round trip. Replacements and an already-tall composer must remeasure so the
// textarea can shrink back to its natural height.
// Parse min-height with a STRICT finite-pixel check: getComputedStyle can
// return a non-px value (e.g. a percentage `min-height`) that parseFloat would
// read as a bogus pixel number (parseFloat("50%")===50), which would wrongly
// enable the fast path and leave the composer stuck tall. Reject anything that
// is not exactly "<number>px" so those cases fail closed to the full resize.
const _composerStyle=_isAppendOnly&&_fitsCurrentHeight?getComputedStyle(el):null;
const _composerPx=(raw)=>/^(?:\d+(?:\.\d+)?|\.\d+)px$/.test(raw==null?'':String(raw))?parseFloat(raw):NaN;
const _minHeightRaw=_composerStyle?_composerStyle.minHeight:'';
const _minHeight=_composerPx(_minHeightRaw);
// The ONE-ROW height the composer naturally settles at is line-height +
// vertical padding + borders (~48px at the stock font), which is TALLER than
// the CSS min-height (44px). Comparing el.offsetHeight against min-height
// alone therefore never matched in a real browser, so this skip was dead code
// and EVERY append keystroke paid the height:'auto' round trip below — and
// that scrollHeight read forces a synchronous layout of the whole document,
// transcript included. The cost grows with the rendered transcript: on a
// 758-message transcript (31k DOM nodes) a keystroke measured ~168ms median
// echo latency at 6x CPU throttle vs 72ms with the transcript detached.
// Accept the natural one-row height too; an oversized composer still takes the
// full resize because its offsetHeight exceeds that ceiling by far.
const _lineHeight=_composerStyle?_composerPx(_composerStyle.lineHeight):NaN;
const _naturalRowHeight=Number.isFinite(_lineHeight)
?_lineHeight+(_composerPx(_composerStyle.paddingTop)||0)+(_composerPx(_composerStyle.paddingBottom)||0)
+(_composerPx(_composerStyle.borderTopWidth)||0)+(_composerPx(_composerStyle.borderBottomWidth)||0)
:NaN;
const _rowCeiling=Number.isFinite(_naturalRowHeight)&&Number.isFinite(_minHeight)?Math.max(_minHeight,_naturalRowHeight):_minHeight;
const _isAtMinimumHeight=Number.isFinite(_rowCeiling)&&el.offsetHeight<=Math.ceil(_rowCeiling)+1;
if(_isAppendOnly&&_fitsCurrentHeight&&_isAtMinimumHeight){
_composerLastResizeValue=_nextValue;
updateSendBtn();
return;
}
const _prevComposerH=el.offsetHeight;
// #5514: autoResize() momentarily sets the textarea to height:'auto' (collapses
// a multi-row composer toward its 1-row min) before reading scrollHeight and
// restoring the measured height. That transient collapse GROWS the flex:1
// #messages viewport, and reading scrollHeight forces a synchronous reflow — so
// the browser CLAMPS a bottom-anchored scrollTop DOWN by the collapse delta and
// does NOT restore it when the height snaps back. The reader is left stranded
// Δpx above the bottom on EVERY keystroke while the composer is multi-row (Δ ∝
// composer height), and the clamp's async scroll event also sticky-unpins the
// reader (_messageUserUnpinned=true), dead-ending the grow-path re-pin below and
// stream auto-follow until they manually scroll back. #5516's net-growth gate
// never caught this because a steady-state keystroke has no NET height change.
// Root-cause fix: snapshot the transcript's scrollTop BEFORE the height
// round-trip and restore it AFTER, undoing the transient clamp within the same
// synchronous task so the poisoning scroll event never fires. This protects
// pinned readers AND near-bottom readers who scrolled up to re-read (their exact
// position is preserved), takes no _programmaticScroll latch, and is inert to
// iOS dynamic-toolbar reflows. A genuine NET grow/shrink still lands the reader
// Δnet off-bottom; the grow-gated re-pin below (and the #composerWrap
// ResizeObserver) then snap a still-pinned reader to the true bottom.
const _msgs=$('messages');
const _prevScrollTop=_msgs?_msgs.scrollTop:0;
el.style.height='auto';
el.style.height=Math.min(el.scrollHeight,200)+'px';
_composerLastResizeValue=_nextValue;
if(_msgs&&_msgs.scrollTop!==_prevScrollTop) _msgs.scrollTop=_prevScrollTop;
updateSendBtn();
// Genuine NET growth (a new row that keeps the composer taller than before)
// still shrinks the settled viewport, so a pinned reader must be re-pinned to
// the true bottom. Guarded to fire only on real growth and only when genuinely
// pinned (the helper no-ops for a scrolled-away reader). The #composerWrap
// ResizeObserver is the safety net for growth paths that don't route here.
if(el.offsetHeight>_prevComposerH && typeof _repinMessagesAfterComposerResize==='function') _repinMessagesAfterComposerResize();
}
function scheduleComposerAutoResize(){
if(typeof requestAnimationFrame!=='function'){autoResize();return;}
if(_composerAutoResizeRaf) return;
_composerAutoResizeRaf=requestAnimationFrame(()=>{
_composerAutoResizeRaf=0;
autoResize();
});
}
// ── YOLO mode state ──
// Session-scoped; stored server-side in memory (tools/approval.py).
// Lifecycle:
// • Page reload: state PERSISTS — _fetchYoloState() re-syncs from backend.
// • Cross-tab: state is SHARED — enabling YOLO in Tab A affects Tab B for
// the same session (both poll the same server-side flag).
// • Server restart: state is LOST — in-memory only, not persisted to disk.
// • Session switch: state resets — loadSession() clears _yoloEnabled and
// fetches the new session's state.
let _yoloEnabled = false;
async function _fetchYoloState(sid) {
try {
const data = await api('/api/session/yolo?session_id=' + encodeURIComponent(sid));
_yoloEnabled = !!data.yolo_enabled;
_updateYoloPill();
} catch (_) { /* ignore */ }
}
function _updateYoloPill() {
const pill = $('yoloPill');
if (!pill) return;
pill.style.display = _yoloEnabled ? '' : 'none';
if (_yoloEnabled) {
pill.title = t('yolo_pill_title_active');
pill.setAttribute('data-i18n-title', 'yolo_pill_title_active');
}
if (typeof applyLocaleToDOM === 'function') applyLocaleToDOM();
}
async function toggleYoloFromApproval() {
const owner = _captureApprovalResponseOwner();
if (!owner) return false;
return !!(await respondApproval('once', {yolo: true, owner}));
}
// ── Approval polling ──
let _approvalPollTimer = null;
let _approvalFallbackPollInFlight = false;
let _approvalHideTimer = null;
let _approvalVisibleSince = 0;
let _approvalSignature = '';
const APPROVAL_MIN_VISIBLE_MS = 30000;
// showApprovalCard moved above respondApproval
function _setPromptFlyoutHidden(card, hidden) {
if (!card) return;
if (hidden) {
card.setAttribute("aria-hidden", "true");
card.setAttribute("inert", "");
const markHidden = () => {
if (!card.classList || !card.classList.contains("visible")) card.hidden = true;
};
if (typeof setTimeout === "function") setTimeout(markHidden, 450);
else markHidden();
return;
}
card.hidden = false;
card.setAttribute("aria-hidden", "false");
card.removeAttribute("inert");
// Force the unhidden, pre-visible state to be observed so the existing
// transform/opacity transition can still animate when `.visible` is added.
void card.offsetHeight;
}
function _clearApprovalHideTimer() {
if (_approvalHideTimer) {
clearTimeout(_approvalHideTimer);
_approvalHideTimer = null;
}
}
function _resetApprovalCardState() {
_clearApprovalHideTimer();
_approvalVisibleSince = 0;
_approvalSignature = '';
}
function hideApprovalCard(force=false) {
const card = $("approvalCard");
if (!card) return;
if (!force && _approvalVisibleSince) {
const remaining = APPROVAL_MIN_VISIBLE_MS - (Date.now() - _approvalVisibleSince);
if (remaining > 0) {
const scheduledSignature = _approvalSignature;
_clearApprovalHideTimer();
_approvalHideTimer = setTimeout(() => {
_approvalHideTimer = null;
if (_approvalSignature !== scheduledSignature) return;
hideApprovalCard(true);
}, remaining);
return;
}
}
const preserveDisplayedOwner = _approvalOwnerIdentityMatches(
_approvalDisplayedOwner,
_approvalResponding,
);
_approvalSessionId = null;
_resetApprovalCardState();
if (!preserveDisplayedOwner) _approvalDisplayedOwner = null;
card.classList.remove("visible");
card.classList.remove("collapsed");
_setPromptFlyoutHidden(card, true);
_syncApprovalTranscriptSpace(null);
$("approvalCmd").textContent = "";
$("approvalDesc").textContent = "";
}
// Track session_id of the active approval so respond goes to the right session
let _approvalSessionId = null;
let _approvalCurrentId = null; // approval_id of the card currently shown
let _approvalPendingBySession = new Map();
let _approvalResponding = null;
let _approvalClearedOwner = null;
let _approvalDisplayedOwner = null;
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
// Dismissed approvals are namespaced by session so that two sessions carrying
// the SAME approval_id (e.g. a gateway/run source that reuses externally
// supplied IDs across sessions) can't have a dismissal in one session hide the
// other's still-pending approval. Stored value is "<sid>\u0000<approval_id>".
function _approvalDismissKey(sid, approvalId) {
if (!approvalId) return '';
return String(sid || '') + '\u0000' + String(approvalId);
}
function _getDismissedApprovals() {
try { return JSON.parse(localStorage.getItem(_DISMISSED_APPROVALS_KEY) || '[]'); }
catch (_) { return []; }
}
function _isApprovalDismissed(sid, approvalId) {
const key = _approvalDismissKey(sid, approvalId);
if (!key) return false;
return _getDismissedApprovals().includes(key);
}
function _markApprovalDismissed(sid, approvalId) {
const key = _approvalDismissKey(sid, approvalId);
if (!key) return;
const set = _getDismissedApprovals().filter(k => k !== key);
set.push(key);
try { localStorage.setItem(_DISMISSED_APPROVALS_KEY, JSON.stringify(set.slice(-100))); }
catch (_) {}
}
function _unmarkApprovalDismissed(sid, approvalId) {
const key = _approvalDismissKey(sid, approvalId);
if (!key) return;
const set = _getDismissedApprovals().filter(k => k !== key);
try { localStorage.setItem(_DISMISSED_APPROVALS_KEY, JSON.stringify(set)); }
catch (_) {}
}
function _promptActiveSessionId() {
return (S.session && S.session.session_id) || null;
}
function _approvalPromptBelongsToActiveSession(sid) {
return !!(sid && _promptActiveSessionId() === sid);
}
function activeSessionHasPendingPromptAttention() {
const sid = _promptActiveSessionId();
return !!(sid && (
_approvalPendingBySession.has(sid) ||
_clarifyPendingBySession.has(sid)
));
}
function _rememberApprovalPending(pending, pendingCount) {
if (!pending) return null;
const sid = pending._session_id || _promptActiveSessionId();
if (!sid) return null;
const nextPending = {...pending, _session_id: sid};
_approvalPendingBySession.set(sid, {pending: nextPending, pendingCount: pendingCount || 1});
return sid;
}
function _clearApprovalPendingForSession(sid) {
if (sid) {
_approvalPendingBySession.delete(sid);
if (typeof syncTopbar === 'function') syncTopbar();
}
}
function _hideApprovalCardIfOwner(sid, force=false) {
if (!sid || _approvalSessionId === sid) hideApprovalCard(force);
}
function _approvalPollingSessionMissingOrMismatched(sid) {
return !sid || !S.session || S.session.session_id !== sid;
}
function _renderPendingApprovalForActiveSession() {
const sid = _promptActiveSessionId();
if (!sid) return;
if (_approvalSessionId && _approvalSessionId !== sid) hideApprovalCard(true);
const entry = _approvalPendingBySession.get(sid);
if (entry) showApprovalCard(entry.pending, entry.pendingCount);
}
function _approvalMirrorOwnerFor(sid, approvalId) {
const entry = _approvalPendingBySession.get(sid);
const pending = entry && entry.pending;
if (!pending || pending.approval_id !== approvalId) return {runId: '', mirrorToken: ''};
const runId = String(pending.run_id || '').trim();
const mirrorToken = String(pending._gateway_mirror_token || '').trim();
return runId && mirrorToken ? {runId, mirrorToken} : {runId: '', mirrorToken: ''};
}
function _approvalOwnerForPending(sid, pending) {
if (!pending) return null;
const approvalId = pending.approval_id || null;
if (!sid || !approvalId) return null;
const runId = String(pending.run_id || '').trim();
const mirrorToken = String(pending._gateway_mirror_token || '').trim();
return {
sid,
approvalId,
runId: runId && mirrorToken ? runId : '',
mirrorToken: runId && mirrorToken ? mirrorToken : '',
};
}
function _approvalOwnerIdentityMatches(left, right) {
return !!(
left &&
right &&
left.sid === right.sid &&
left.approvalId === right.approvalId &&
left.runId === right.runId &&
left.mirrorToken === right.mirrorToken
);
}
function _captureApprovalResponseOwner() {
const card = $("approvalCard");
const sid = _approvalSessionId;
const approvalId = _approvalCurrentId;
if (!card || !card.classList.contains("visible") || !sid || !approvalId) return null;
if (!S.session || S.session.session_id !== sid) return null;
if (
!_approvalDisplayedOwner ||
_approvalDisplayedOwner.sid !== sid ||
_approvalDisplayedOwner.approvalId !== approvalId
) return null;
return {..._approvalDisplayedOwner, generation: _loadSessionGeneration};
}
function _approvalResponseOwnerIsCurrent(owner) {
return !!(
owner &&
S.session &&
S.session.session_id === owner.sid &&
_loadSessionGeneration === owner.generation &&
_approvalOwnerIdentityMatches(_approvalDisplayedOwner, owner)
);
}
function _approvalResponseMatches(
sid,
approvalId,
generation = _loadSessionGeneration,
mirrorOwner = _approvalMirrorOwnerFor(sid, approvalId),
) {
return !!(
_approvalResponding &&
_approvalResponding.sid === sid &&
_approvalResponding.generation === generation &&
_approvalResponding.approvalId === approvalId &&
_approvalResponding.runId === mirrorOwner.runId &&
_approvalResponding.mirrorToken === mirrorOwner.mirrorToken
);
}
function _releaseApprovalResponseOwner(owner) {
if (_approvalResponseMatches(owner.sid, owner.approvalId, owner.generation, owner)) {
_approvalResponding = null;
}
const card = $("approvalCard");
if (
_approvalOwnerIdentityMatches(_approvalDisplayedOwner, owner) &&
(!card || !card.classList.contains("visible"))
) {
_approvalDisplayedOwner = null;
}
}
function _approvalClearedOwnerMayRefresh(owner) {
return !!(
_approvalClearedOwner === owner &&
S.session &&
S.session.session_id === owner.sid &&
_loadSessionGeneration === owner.generation &&
_approvalSessionId === null &&
_approvalCurrentId === null
);
}
function _setApprovalControlsDisabled(choice, disabled) {
const loadingId = choice === "skipAll"
? "approvalSkipAll"
: (choice ? "approvalBtn" + choice.charAt(0).toUpperCase() + choice.slice(1) : null);
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny","approvalSkipAll"].forEach(id => {
const b = $(id);
if (!b) return;
b.disabled = !!disabled;
if (disabled && b.id === loadingId) {
b.classList.add("loading");
} else {
b.classList.remove("loading");
}
});
}
function showApprovalForSession(sid, pending, pendingCount) {
if (!pending) return;
pending._session_id = sid;
showApprovalCard(pending, pendingCount);
}
function showApprovalCard(pending, pendingCount) {
const sid = _rememberApprovalPending(pending, pendingCount);
if (!_approvalPromptBelongsToActiveSession(sid)) return;
if (pending && pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) return;
_approvalClearedOwner = null;
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
const cmd = pending.command || "";
const sig = JSON.stringify({
desc,
cmd,
sid: pending._session_id || (S.session && S.session.session_id) || null,
approval_id: pending.approval_id || null,
run_id: pending.run_id || null,
mirror_token: pending._gateway_mirror_token || null,
});
const card = $("approvalCard");
const sameApproval = card.classList.contains("visible") && _approvalSignature === sig;
$("approvalDesc").textContent = desc;
$("approvalCmd").textContent = cmd;
_approvalSessionId = sid;
_approvalCurrentId = pending.approval_id || null;
_approvalDisplayedOwner = _approvalOwnerForPending(sid, pending);
_approvalSignature = sig;
// Show "1 of N" counter when multiple approvals are queued
const counter = $("approvalCounter");
if (counter) {
if (pendingCount && pendingCount > 1) {
counter.textContent = (typeof t === "function")
? t("approval_pending_count", pendingCount)
: ("1 of " + pendingCount + " pending");
counter.style.display = "";
} else {
counter.style.display = "none";
}
}
if (!sameApproval) {
_approvalVisibleSince = Date.now();
_clearApprovalHideTimer();
// A distinct approval must always render expanded — never inherit a prior
// approval's collapsed state, which would hide its command + action buttons. (#3515)
card.classList.remove("collapsed");
}
const responding = _approvalResponseMatches(sid, _approvalCurrentId);
_setApprovalControlsDisabled(
responding ? (_approvalResponding.controlChoice || _approvalResponding.choice) : null,
responding,
);
_setPromptFlyoutHidden(card, false);
card.classList.add("visible");
_syncApprovalCollapseButton(card);
_syncApprovalTranscriptSpace(card, {immediate: true});
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
const onceBtn = $("approvalBtnOnce");
if (onceBtn && document.activeElement !== $('msg')) {
setTimeout(() => onceBtn.focus({preventScroll: true}), 50);
}
if (typeof syncTopbar === 'function') syncTopbar();
}
function dismissApprovalCard() {
const sid = _approvalSessionId;
if (_approvalCurrentId) _markApprovalDismissed(sid, _approvalCurrentId);
hideApprovalCard(true);
if (sid) _clearApprovalPendingForSession(sid);
}
function _syncApprovalCollapseButton(card) {
const collapse = $("approvalCollapse");
if (!collapse || !card) return;
const collapsed = card.classList.contains("collapsed");
collapse.setAttribute("aria-expanded", collapsed ? "false" : "true");
// Icon swap: chevron-down when expanded (click to collapse), chevron-up when collapsed (click to expand)
const polyline = collapse.querySelector("svg polyline");
if (polyline) polyline.setAttribute("points", collapsed ? "18 15 12 9 6 15" : "6 9 12 15 18 9");
const label = collapsed ? "Expand approval" : "Collapse approval";
collapse.setAttribute("aria-label", label);
collapse.title = label;
}
function _approvalMessagesNearBottom(messages) {
if (!messages) return false;
return messages.scrollHeight - messages.scrollTop - messages.clientHeight < 150;
}
function _syncApprovalTranscriptSpace(card, opts) {
opts = opts || {};
const messages = $("messages");
if (!messages) return;
const wasNearBottom = _approvalMessagesNearBottom(messages);
if (!card || !card.classList.contains("visible")) {
messages.classList.remove("approval-open");
messages.classList.remove("approval-collapsed");
messages.style.removeProperty("--approval-card-height");
messages.style.removeProperty("--approval-dock-height");
if (wasNearBottom && typeof scrollToBottom === "function" && typeof requestAnimationFrame === "function") {
requestAnimationFrame(scrollToBottom);
}
return;
}
const collapsed = card.classList.contains("collapsed");
messages.classList.add("approval-open");
messages.classList.toggle("approval-collapsed", collapsed);
const measure = () => {
if (!card.classList.contains("visible")) return;
const target = collapsed ? card : (card.querySelector(".approval-inner") || card);
const h = target && target.getBoundingClientRect().height;
if (h > 0) {
messages.style.setProperty(collapsed ? "--approval-dock-height" : "--approval-card-height", Math.ceil(h + 24) + "px");
}
if (wasNearBottom && typeof scrollToBottom === "function") scrollToBottom();
};
if (opts.immediate) measure();
if (typeof requestAnimationFrame === "function") requestAnimationFrame(measure);
setTimeout(measure, 420);
}
function _restoreFailedApprovalResponse(owner, errMsg) {
const isCurrent = _approvalResponseOwnerIsCurrent(owner);
_releaseApprovalResponseOwner(owner);
if (!isCurrent) return;
_setApprovalControlsDisabled(null, false);
_renderPendingApprovalForActiveSession();
if (typeof showToast === "function") showToast(errMsg, 5000);
if (typeof setStatus === "function") setStatus(errMsg);
}
function _applyApprovalYoloProjection(result) {
if (!result || typeof result.yolo_enabled !== "boolean") return;
_yoloEnabled = result.yolo_enabled;
_updateYoloPill();
}
function toggleApprovalCardCollapsed(forceCollapsed) {
const card = $("approvalCard");
if (!card) return;
const collapsed = typeof forceCollapsed === "boolean" ? forceCollapsed : !card.classList.contains("collapsed");
card.classList.toggle("collapsed", collapsed);
_syncApprovalCollapseButton(card);
_syncApprovalTranscriptSpace(card, {immediate: true});
}
async function respondApproval(choice, options = {}) {
const owner = options.owner || _captureApprovalResponseOwner();
if (!_approvalResponseOwnerIsCurrent(owner)) return false;
const {sid, approvalId} = owner;
if (_approvalResponseMatches(sid, approvalId, owner.generation, owner)) return false;
_approvalClearedOwner = null;
_unmarkApprovalDismissed(sid, approvalId);
const controlChoice = options.yolo ? "skipAll" : choice;
_approvalResponding = {...owner, choice};
_approvalResponding.controlChoice = controlChoice;
_setApprovalControlsDisabled(controlChoice, true);
try {
const result = await api("/api/approval/respond", {
method: "POST",
body: JSON.stringify({
session_id: sid,
choice,
approval_id: approvalId,
...(owner.runId ? {run_id: owner.runId} : {}),
...(owner.mirrorToken ? {mirror_token: owner.mirrorToken} : {}),
...(options.yolo ? {yolo: true} : {}),
})
});
if (!_approvalResponseOwnerIsCurrent(owner)) {
_releaseApprovalResponseOwner(owner);
return false;
}
if (result && result.ok) {
_releaseApprovalResponseOwner(owner);
if (options.yolo) _applyApprovalYoloProjection(result);
const pendingEntry = _approvalPendingBySession.get(sid);
const pendingOwner = _approvalMirrorOwnerFor(sid, approvalId);
const samePending = !!(
pendingEntry &&
pendingEntry.pending &&
pendingEntry.pending.approval_id === approvalId &&
pendingOwner.runId === owner.runId &&
pendingOwner.mirrorToken === owner.mirrorToken
);
if (samePending) _clearApprovalPendingForSession(sid);
_approvalSessionId = null;
_approvalCurrentId = null;
_approvalClearedOwner = owner;
hideApprovalCard(true);
if (result.stale_cleared) {
void (async () => {
if (!_approvalClearedOwnerMayRefresh(owner)) return;
try {
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid), {timeoutToast: false});
if (!_approvalClearedOwnerMayRefresh(owner)) return;
_approvalClearedOwner = null;
if (data && data.pending) showApprovalForSession(sid, data.pending, data.pending_count || 1);
} catch (_) {
if (_approvalClearedOwner === owner) _approvalClearedOwner = null;
}
})();
}
if (options.yolo) showToast(t(_yoloEnabled ? 'yolo_enabled' : 'yolo_disabled'));
return options.yolo ? result : true;
}
const errMsg = (result && result.error) || "Approval response not accepted.";
_restoreFailedApprovalResponse(owner, errMsg);
return false;
} catch(e) {
let errorPayload = null;
if (e && typeof e.body === 'string') {
try { errorPayload = JSON.parse(e.body); } catch (_) { /* non-JSON HTTP error */ }
}
const errMsg = (errorPayload && (errorPayload.error || errorPayload.message))
|| (e && e.message)
|| (t("approval_responding") + " failed");
if (!_approvalResponseOwnerIsCurrent(owner)) {
_releaseApprovalResponseOwner(owner);
return false;
}
if (options.yolo) _applyApprovalYoloProjection(errorPayload);
_restoreFailedApprovalResponse(owner, errMsg);
return false;
}
}
function startApprovalPolling(sid) {
stopApprovalPolling();
_approvalPollingSessionId = sid || null;
// Use HTTP polling instead of SSE to avoid browser connection pool exhaustion.
// Browsers limit to 6 concurrent HTTP connections per origin over HTTP/1.1.
// With 6 persistent SSE streams (sessions/events, gateway/stream,
// session/stream, approval/stream, clarify/stream, chat/stream), the pool
// fills and all fetch() requests queue indefinitely. The server responds
// normally (curl works), but the browser has no available sockets.
//
// This was introduced in v0.51.340 when /api/session/stream was added as
// the 6th persistent SSE connection. Until we multiplex streams or serve
// SSE from a separate origin, use HTTP polling to free 2 connection slots.
// (1.5-second interval, acceptable tradeoff)
_startApprovalFallbackPoll(sid);
}
let _approvalEventSource = null;
let _approvalSSEHealthTimer = null;
let _approvalPollingSessionId = null;
function _startApprovalFallbackPoll(sid) {
// Run one tick immediately so a session already blocked on a pending approval
// shows its card instantly (the removed SSE 'initial' event used to do this);
// then poll on the 1500ms cadence. (#3913 SHOULD-FIX)
const _tick = async () => {
if (_approvalPollingSessionMissingOrMismatched(sid)) {
stopApprovalPolling(); _hideApprovalCardIfOwner(sid, true); return;
}
if (_approvalFallbackPollInFlight) return;
_approvalFallbackPollInFlight = true;
try {
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
if (data.pending) { showApprovalForSession(sid, data.pending, data.pending_count||1); }
else if (!_approvalPollingSessionMissingOrMismatched(sid)) {
const _resolvedEntry = _approvalPendingBySession.get(sid);
_clearApprovalPendingForSession(sid);
const _resolvedId = _resolvedEntry && _resolvedEntry.pending && _resolvedEntry.pending.approval_id;
if (_resolvedId) _unmarkApprovalDismissed(sid, _resolvedId);
_hideApprovalCardIfOwner(sid);
if (!S.busy) {
stopApprovalPollingForSession(sid);
}
}
} catch(e) { /* ignore poll errors */ }
finally { _approvalFallbackPollInFlight = false; }
};
_approvalPollTimer = setInterval(_tick, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
_tick();
}
function stopApprovalPollingForSession(sid) {
if(sid && _approvalPollingSessionId && _approvalPollingSessionId!==sid) return;
stopApprovalPolling();
}
function stopApprovalPolling() {
if (_approvalPollTimer) { clearInterval(_approvalPollTimer); _approvalPollTimer = null; }
if (_approvalEventSource) { try { if(_approvalEventSource.readyState!==2)_approvalEventSource.close(); } catch(_){} _approvalEventSource = null; }
if (_approvalSSEHealthTimer) { clearInterval(_approvalSSEHealthTimer); _approvalSSEHealthTimer = null; }
_approvalFallbackPollInFlight = false;
_approvalPollingSessionId = null;
}
// ── Session-scoped SSE stream (Option X) ──────────────────────────────────
// Long-lived EventSource bound to /api/session/stream?session_id=<sid>.
// Lives across agent turns (unlike the per-turn /api/chat/stream which is
// torn down at end-of-turn). Carries bg_task_complete events fired while no
// turn is active — the architectural fix for the notify_on_complete wakeup
// gap that #2242 + #2279 papered over.
//
// Lifecycle: opened on session mount (loadSession / newSession), closed on
// session switch / unmount. The browser closes it implicitly on tab close
// (server detects disconnect via the SSE read-loop and unsubscribes).
let _sessionEventSource = null;
let _sessionStreamSessionId = null;
let _sessionStreamReconnectTimer = null;
// Holds the session id across a hidden-tab close so the visibility handler can
// reopen the per-session SSE on re-show (stopSessionStream nulls _sessionStreamSessionId).
let _sessionStreamHiddenSid = null;
// Hidden-tab active-stream poll (Defect B continuation): while the tab is
// hidden we do NOT hold the persistent per-session SSE open (connection-pool
// budget — see #3992/#4151). But a server-initiated turn (self-wake / cron /
// restart hook) fans `server_turn_started` onto that channel, so a hidden tab
// would miss it and only reconcile on the next interaction ("过了15秒没弹出来").
// Bridge the gap with a lightweight poll of /api/session/status (a single
// short-lived GET, NOT a held connection) that attaches the live renderer when
// it sees an active_stream_id. Cleared on re-show (the real SSE takes over) and
// on session switch.
let _sessionStreamHiddenPollTimer = null;
let _sessionStreamHiddenPollSid = null;
// Bounded-retry budget for the hidden poll's "attach returned false → keep
// polling" path (PR #5266 follow-up gate). A never-current pane (multi-pane:
// another session stays on screen) would otherwise poll /api/session/status
// every 6s forever. Cap the consecutive-false retries per (sid, streamId); once
// the budget is exhausted, stop the hidden poll and rely on the normal
// session-switch / loadSession / visible-tab SSE to reattach later.
let _sessionStreamHiddenPollFalseStreamId = null;
let _sessionStreamHiddenPollFalseCount = 0;
const _SESSION_STREAM_HIDDEN_POLL_MAX_FALSE = 20; // ~2 min at the 6s cadence
// Attach the existing chat-stream renderer to a server-created stream. Shared
// by the `server_turn_started` SSE handler (visible tab) and the hidden-tab
// active-stream poll. Idempotent per (sid, streamId): bails if this tab is
// already rendering that stream. `recovered` routes through the reconnecting
// (replay) path so the renderer rebuilds from the run journal mid-flight.
//
// Returns true when this tab is now responsible for rendering the stream
// (either the renderer was attached, OR a renderer was already attached to
// the same (sid,streamId) — both are "stream is in good hands"). Returns
// false when the attach was NOT consummated — sid not on screen (multi-pane:
// the active pane is a different session) or input invalid or thrown — so
// the hidden-tab poll caller can keep polling and try again on a later tick
// (e.g. once the user switches the pane back to `sid`). Without this
// signal, a poll that fires while another session is in the current pane
// would attach nothing AND stop polling, leaving the turn invisible until
// the next user interaction.
function _attachServerInitiatedStream(sid, streamId, recovered) {
let handedOff = false;
try {
streamId = String(streamId || '');
if (!streamId) return false;
const isCurrent = (typeof _isSessionCurrentPane === 'function')
? _isSessionCurrentPane(sid)
: (S.session && S.session.session_id === sid);
// Multi-pane edge: caller's sid is not the active pane. Don't attach to
// a different pane's UI; tell the poll to keep trying.
if (!isCurrent) return false;
// Already rendering this exact stream — treat as success so the poll
// stops cleanly (renderer owns the stream from here).
if (S.activeStreamId === streamId) return true;
const existingLive = (typeof LIVE_STREAMS !== 'undefined') ? LIVE_STREAMS[sid] : null;
if (existingLive && existingLive.streamId === streamId) return true;
S.busy = true;
S.activeStreamId = streamId;
if (S.session && S.session.session_id === sid) {
S.session.active_stream_id = streamId;
if (!S.session.pending_started_at) S.session.pending_started_at = Date.now()/1000;
}
if (typeof ensureLiveWorklogShell === 'function') ensureLiveWorklogShell();
else if (typeof appendThinking === 'function') appendThinking();
if (typeof updateSendBtn === 'function') updateSendBtn();
if (typeof setComposerStatus === 'function') setComposerStatus('');
if (typeof syncTopbar === 'function') syncTopbar();
if (typeof startApprovalPolling === 'function') startApprovalPolling(sid);
if (typeof startClarifyPolling === 'function') startClarifyPolling(sid);
if (typeof attachLiveStream === 'function') {
attachLiveStream(
sid, streamId,
(S.session && S.session.pending_attachments) || [],
recovered ? {reconnecting: true} : {},
);
// The renderer now owns the stream. Any failure AFTER this point (e.g. a
// post-attach UI refresh throwing) is NOT an attach failure — the stream is
// in good hands, so the caller must not be told to keep polling/retrying.
handedOff = true;
}
if (typeof renderSessionList === 'function') void renderSessionList();
return true;
} catch (_) {
try { console.error('hidden-tab server-initiated attach failed', _); } catch (_e) {}
// If the stream was already handed to the renderer, the attach DID succeed;
// a later UI-refresh throw must not be reported as failure (would make the
// poll keep retrying an already-attached stream).
if (handedOff) return true;
// Otherwise the attach threw mid-setup, leaving partial state (S.busy /
// S.activeStreamId / S.session.active_stream_id were set before the DOM
// calls). Clear it so the next hidden-poll tick doesn't see a stale
// activeStreamId, exit early as "already attached", and wedge the turn
// invisible with the composer stuck busy. Only clear when THIS pane/session
// still owns the sid/streamId we were attaching (don't stomp a newer stream
// that a concurrent path may have started).
try {
const stillOurs = (S.session && S.session.session_id === sid) &&
(S.activeStreamId === streamId || (S.session && S.session.active_stream_id === streamId));
if (stillOurs) {
S.busy = false;
S.activeStreamId = null;
if (S.session) S.session.active_stream_id = null;
if (typeof updateSendBtn === 'function') updateSendBtn();
if (typeof syncTopbar === 'function') syncTopbar();
if (typeof renderSessionList === 'function') void renderSessionList();
}
} catch (_e2) {}
return false;
}
}
// Poll /api/session/status (~6s) for an active stream while the tab is hidden.
// One short GET per tick — does not consume a persistent connection-pool slot.
// On hit, attach via the reconnecting/replay path (the turn is already
// mid-flight) and stop polling; the renderer owns it from here.
function _startHiddenActiveStreamPoll(sid) {
if (!sid) return;
_stopHiddenActiveStreamPoll();
_sessionStreamHiddenPollSid = sid;
const tick = () => {
// Stop conditions: tab became visible (real SSE takes over), session
// switched, or we're already rendering a stream.
if (typeof document !== 'undefined' && !document.hidden) { _stopHiddenActiveStreamPoll(); return; }
if (_sessionStreamHiddenPollSid !== sid) { _stopHiddenActiveStreamPoll(); return; }
if (S.activeStreamId) return; // already rendering; wait it out
try {
fetch(_apiUrl('api/session/status?session_id=' + encodeURIComponent(sid)), {credentials: 'same-origin'})
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d || _sessionStreamHiddenPollSid !== sid) return;
const streamId = d.active_stream_id;
if (streamId && S.activeStreamId !== String(streamId)) {
// Server-initiated turn in flight while hidden → attach as replay.
// Only stop polling when the attach actually consummated. In the
// multi-pane edge where the active pane is a different session,
// _attachServerInitiatedStream returns false (sid not on screen), so
// we keep polling and re-try on a later tick — e.g. once the user
// switches the active pane back to `sid`, or by then the turn has
// finished and status returns active_stream_id=null naturally.
const streamKey = String(streamId);
// Reset the bounded-retry budget whenever the stream id changes (a new
// server-initiated turn deserves a fresh set of retries).
if (_sessionStreamHiddenPollFalseStreamId !== streamKey) {
_sessionStreamHiddenPollFalseStreamId = streamKey;
_sessionStreamHiddenPollFalseCount = 0;
}
const attached = _attachServerInitiatedStream(sid, streamId, true);
if (attached) {
_stopHiddenActiveStreamPoll();
} else {
// Bounded give-up: a never-current pane would poll forever otherwise.
// Count consecutive false attaches for this stream id; once the
// budget is spent, stop the hidden poll. A later session-switch /
// loadSession / visible-tab SSE will reattach if the turn is still
// live by then.
_sessionStreamHiddenPollFalseCount += 1;
if (_sessionStreamHiddenPollFalseCount >= _SESSION_STREAM_HIDDEN_POLL_MAX_FALSE) {
_stopHiddenActiveStreamPoll();
}
}
}
})
.catch(() => {});
} catch (_) {}
};
_sessionStreamHiddenPollTimer = setInterval(tick, 6000);
// Fire one immediately so a turn already running when we go hidden is caught
// without waiting a full interval.
tick();
}
function _stopHiddenActiveStreamPoll() {
if (_sessionStreamHiddenPollTimer) { clearInterval(_sessionStreamHiddenPollTimer); _sessionStreamHiddenPollTimer = null; }
_sessionStreamHiddenPollSid = null;
// Reset the bounded-retry budget so a fresh poll never inherits a stale count.
_sessionStreamHiddenPollFalseStreamId = null;
_sessionStreamHiddenPollFalseCount = 0;
}
function _chatStreamActiveForSession(sid) {
if (!sid) return false;
if (typeof LIVE_STREAMS !== 'undefined' && LIVE_STREAMS[sid]) return true;
return !!(
S &&
S.session &&
S.session.session_id === sid &&
S.activeStreamId
);
}
function _suspendSessionStreamForLiveChat(sid) {
if (!sid) return;
if (_sessionStreamSessionId !== sid) return;
_sessionStreamHiddenSid = sid;
stopSessionStream();
}
function _resumeSessionStreamAfterLiveChat(sid) {
if (!sid) return;
setTimeout(() => {
if (!S || !S.session || S.session.session_id !== sid) return;
if (_chatStreamActiveForSession(sid)) return;
_sessionStreamHiddenSid = null;
startSessionStream(sid);
}, 0);
}
function startSessionStream(sid) {
if (!sid) return;
// Already on this session? No-op (loadSession is a no-op when re-selecting
// the same session; this defends against external re-callers).
if (_sessionStreamSessionId === sid && _sessionEventSource) return;
// While the visible conversation has a live token stream, /api/chat/stream
// already carries bg_task_complete and terminal events for this turn. Keeping
// /api/session/stream open at the same time burns one of Chrome's six
// same-origin HTTP/1.1 sockets and can starve ordinary /api/session fetches.
if (_chatStreamActiveForSession(sid)) {
_sessionStreamHiddenSid = sid;
return;
}
stopSessionStream();
_sessionStreamSessionId = sid;
// Visibility hook (install once) — mirror ensureSessionEventsSSE() pattern.
// Capture the active session id into a dedicated var BEFORE closing, because
// stopSessionStream() nulls _sessionStreamSessionId — so the reopen path can't
// rely on it (that was the bug: the stream never reopened on tab re-show).
if (typeof document !== 'undefined' && !document._hermesSessionStreamVisibilityHook) {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
_sessionStreamHiddenSid = _sessionStreamSessionId;
stopSessionStream();
// Tab went to background: don't hold the SSE, but bridge
// server-initiated turns (self-wake / cron / restart) with the
// lightweight status poll so they still render without interaction.
// (stopSessionStream cleared any prior poll; start a fresh one for the
// session we just hid.)
if (_sessionStreamHiddenSid) _startHiddenActiveStreamPoll(_sessionStreamHiddenSid);
} else if (_sessionStreamHiddenSid) {
const resumeSid = _sessionStreamHiddenSid;
_sessionStreamHiddenSid = null;
void startSessionStream(resumeSid);
}
});
document._hermesSessionStreamVisibilityHook = true;
}
// Don't open when tab is hidden — saves connection pool slots. Preserve the
// pending session id so the visibility handler reopens it on re-show (a session
// loaded/restored while the tab is already hidden must still reattach).
if (typeof document !== 'undefined' && document.hidden) {
_sessionStreamHiddenSid = sid;
// Don't hold the SSE open while hidden, but DO bridge server-initiated
// turns (self-wake / cron / restart) with a lightweight status poll so the
// turn renders without waiting for the user to interact.
_startHiddenActiveStreamPoll(sid);
return;
}
// Tab is visible — the real SSE owns live-view; ensure no stale hidden poll.
_stopHiddenActiveStreamPoll();
try {
// Report our last-known message_count so the server's on-subscribe
// self-heal can detect a server-initiated turn (self-wake / cron / restart)
// that started AND finished entirely inside an SSE gap — the fire-and-forget
// `server_turn_started` was missed AND the run already cleared from
// ACTIVE_RUNS by the time we reconnect, so the `server_turn_started` replay
// finds nothing. When the server is ahead of this count it emits a
// `session-updated` frame (handled below) and we incrementally sync. Use the
// session's full message_count (same basis the server persists), NOT
// S.messages.length, which is only the rendered tail window for long
// sessions and would false-trigger a reload on every reconnect.
let _knownCount = '';
try {
if (S.session && S.session.session_id === sid && Number.isFinite(Number(S.session.message_count))) {
_knownCount = String(Number(S.session.message_count));
}
} catch (_) {}
const _streamUrl = 'api/session/stream?session_id=' + encodeURIComponent(sid)
+ (_knownCount !== '' ? '&known_count=' + encodeURIComponent(_knownCount) : '');
const es = new EventSource(_apiUrl(_streamUrl));
_sessionEventSource = es;
es.addEventListener('initial', () => { /* connection confirmed */ });
es.addEventListener('bg_task_complete', e => {
// Shared handler — same dedupe set as the in-turn STREAMS path.
if (typeof _handleBgTaskCompleteEvent === 'function') {
_handleBgTaskCompleteEvent(e, sid, {source: 'session'});
}
});
// ── Visible-tab self-heal: a server-initiated turn finished during an SSE
// gap ─────────────────────────────────────────────────────────────────
// Distinct from `server_turn_started` (which attaches a LIVE stream): this
// frame fires when the server detected, at our (re)subscribe, that a turn
// started AND finished while our EventSource was momentarily down (so we
// missed the live broadcast and the run already cleared). The turn is
// persisted but our transcript is stale and there is NO live stream left to
// attach. Incrementally sync via the SAME swap-in-place path #5189 uses for
// the hidden-tab return (loadSession force + keepStaleUntilLoaded): the new
// transcript replaces the old in a single render frame — NO clear+refetch,
// so the #5177/#5189 blank-gap "jump" is not reintroduced.
// #6999: focusing a backgrounded tab also fires the visibility-recovery
// probe in sessions.js (refreshActiveSessionIfExternallyUpdated), which
// holds the shared _activeSessionExternalRefreshInFlight guard while it
// probes + force-reloads this same session. This handler honors that guard
// so the two paths never start two concurrent loadSession(force) calls
// (double full-transcript fetch + double renderMessages pass = the OOM
// pattern on long sessions). The probe side carries its own
// _loadingSessionId guard; loadSession() keeps its legitimate
// newest-wins supersede semantics untouched.
// #6999 re-gate: while the probe owns the guard, frames are COALESCED via
// _coalesceSessionUpdatedWhileRefreshHeld — the max announced count is
// latched per SID and the owner's finally runs ONE guarded follow-up when
// local state is still behind (a bare return dropped the update; production
// does not guarantee a second event).
es.addEventListener('session-updated', e => {
try {
const d = JSON.parse(e.data || '{}');
const evSid = d.session_id || sid;
if (evSid !== sid) return;
// Only act when this session is the one on screen and we're idle (no
// live turn rendering — that path owns its own message updates).
const isCurrent = (typeof _isSessionCurrentPane === 'function')
? _isSessionCurrentPane(sid)
: (S.session && S.session.session_id === sid);
if (!isCurrent) return;
if (S.activeStreamId) return;
const serverCount = Number(d.message_count);
if (typeof _coalesceSessionUpdatedWhileRefreshHeld === 'function' && _coalesceSessionUpdatedWhileRefreshHeld(sid, serverCount)) return;
// Re-check against our CURRENT known count — a concurrent load may have
// already caught us up between the server's emit and now.
const localCount = (S.session && S.session.session_id === sid && Number.isFinite(Number(S.session.message_count)))
? Number(S.session.message_count)
: (Array.isArray(S.messages) ? S.messages.length : 0);
if (!Number.isFinite(serverCount) || serverCount <= localCount) return;
if (typeof loadSession === 'function') {
void loadSession(sid, {force: true, externalRefreshReason: 'session-updated', keepStaleUntilLoaded: true});
}
} catch (_) {}
});
// ── Defect B: live-view of server-initiated (Option Z) turns ──────────
// The drain thread starts the wakeup turn server-side and the server
// fans a `server_turn_started` {stream_id} frame onto this per-session
// channel. No browser POSTed /api/chat/start, so nothing is attached to
// that STREAMS[stream_id] yet. Attach the EXISTING chat-stream renderer
// (attachLiveStream — the exact path /api/chat/start uses) to the
// server-created stream so the open tab renders the turn live. Reuses
// the one renderer; does NOT hand-roll a second one.
es.addEventListener('server_turn_started', e => {
try {
const d = JSON.parse(e.data || '{}');
const evSid = d.session_id || sid;
const streamId = String(d.stream_id || '');
if (!streamId || evSid !== sid) return;
// `recovered` marks an on-subscribe replay from the server: the tab
// (re)connected to /api/session/stream AFTER the original
// fire-and-forget server_turn_started had already been broadcast, so
// the live stream is mid-flight. Attach via the reconnecting (replay)
// path so the renderer rebuilds from the run journal instead of
// expecting token 0 (which would render a truncated turn). A fresh
// (non-recovered) frame still attaches from the first token.
const recovered = !!d.recovered;
// Only drive the renderer when this session is the one on screen.
const isCurrent = (typeof _isSessionCurrentPane === 'function')
? _isSessionCurrentPane(sid)
: (S.session && S.session.session_id === sid);
if (!isCurrent) return;
// A turn is already rendering in this tab (user-initiated, or we
// already attached to this very stream). attachLiveStream is
// idempotent per (sid, streamId); bail if we're already on it.
if (S.activeStreamId === streamId) return;
const existingLive = (typeof LIVE_STREAMS !== 'undefined') ? LIVE_STREAMS[sid] : null;
if (existingLive && existingLive.streamId === streamId) return;
// Mirror the loadSession reattach setup. For a fresh frame the turn
// renders from its first token; for a recovered (replay) frame
// attachLiveStream reconstructs the in-progress stream.
S.busy = true;
S.activeStreamId = streamId;
if (S.session && S.session.session_id === sid) {
S.session.active_stream_id = streamId;
if (typeof d.pending_started_at === 'number') S.session.pending_started_at = d.pending_started_at;
else if (!S.session.pending_started_at) S.session.pending_started_at = Date.now()/1000;
}
if (typeof ensureLiveWorklogShell === 'function') ensureLiveWorklogShell();
else if (typeof appendThinking === 'function') appendThinking();
if (typeof updateSendBtn === 'function') updateSendBtn();
if (typeof setComposerStatus === 'function') setComposerStatus('');
if (typeof syncTopbar === 'function') syncTopbar();
if (typeof startApprovalPolling === 'function') startApprovalPolling(sid);
if (typeof startClarifyPolling === 'function') startClarifyPolling(sid);
if (typeof attachLiveStream === 'function') {
attachLiveStream(
sid, streamId,
(S.session && S.session.pending_attachments) || [],
recovered ? {reconnecting: true} : {},
);
}
if (typeof renderSessionList === 'function') void renderSessionList();
} catch (_) {}
});
es.onerror = () => {
// Browser already auto-reconnects EventSource on most transient
// failures. We only intervene if the connection has been closed for
// good (readyState === 2) — schedule a one-shot re-open after 5s.
if (es.readyState === 2 && _sessionStreamSessionId === sid) {
if (_sessionStreamReconnectTimer) clearTimeout(_sessionStreamReconnectTimer);
// The CLOSED EventSource (readyState === 2) will never reconnect on
// its own, and startSessionStream's top guard
// (`_sessionStreamSessionId === sid && _sessionEventSource`) would
// short-circuit the re-open while this dead object is still pinned.
// Drop our reference (and close it for good measure) so the timer's
// startSessionStream() reaches stopSessionStream() and builds a FRESH
// EventSource instead of reusing the closed one. Only clear if `es`
// is still the active source — a newer connection may have replaced
// it in the interim (stale onerror from a superseded stream), in
// which case we must not stomp the live one.
if (_sessionEventSource === es) {
try { if(es.readyState!==2)es.close(); } catch (_) {}
_sessionEventSource = null;
}
_sessionStreamReconnectTimer = setTimeout(() => {
_sessionStreamReconnectTimer = null;
if (_sessionStreamSessionId === sid) startSessionStream(sid);
}, 5000);
}
};
} catch(_) {
// EventSource ctor threw — silently disabled; the in-turn STREAMS path
// still works for events that fire during an active turn.
_sessionEventSource = null;
}
}
function stopSessionStream() {
if (_sessionStreamReconnectTimer) { clearTimeout(_sessionStreamReconnectTimer); _sessionStreamReconnectTimer = null; }
_stopHiddenActiveStreamPoll();
if (_sessionEventSource) {
try { if(_sessionEventSource.readyState!==2)_sessionEventSource.close(); } catch(_){}
_sessionEventSource = null;
}
_sessionStreamSessionId = null;
}
// Shared bg_task_complete handler — invoked from BOTH the in-turn STREAMS
// channel (legacy path, still kept as defense-in-depth) AND the session-
// scoped channel (Option X primary path). Dedupes by (session_id, event_id)
// via the Map+TTL ring buffer declared at the top of this module.
// Events without `event_id` are ignored — the server contract guarantees one
// on every completion emit, so a missing key signals a malformed or replayed
// payload we should not surface or ack.
// PR (c) UX surface: post-dedupe the handler marks the session viewed (when
// the session pane is current and the doc is visible+focused), then runs the
// T4 drop-when-focused gate; only out-of-focus or off-pane completions spawn
// a toast. The diagnostic ack POST still fires for both focused and
// unfocused viewers so the server receives the delivery/cleanup signal;
// the focus gate suppresses UI noise only.
function _handleBgTaskCompleteEvent(e, expectedSid, opts) {
try {
const d = JSON.parse(e.data || '{}');
const sid = d.session_id || expectedSid;
if (sid !== expectedSid) return;
const evt_id = d.event_id ? String(d.event_id) : '';
if (!evt_id) return; // server contract requires event_id; ignore otherwise
if (_bgTaskCompleteRingBufferAdd(sid, evt_id)) return; // duplicate
const pid = String(d.task_id || '');
const _viewed = typeof _isSessionActivelyViewed === 'function' && _isSessionActivelyViewed(sid);
if (_viewed) {
try { _markSessionViewed(sid, (S&&S.session&&S.session.session_id===sid)?(S.session.message_count??(S.messages&&S.messages.length)??0):0); } catch(_){}
try { if(typeof _clearSessionCompletionUnread==='function') _clearSessionCompletionUnread(sid); } catch(_){}
} else {
// T4 drop-when-focused: suppress toast only; ack below still fires.
try {
const tid = (d.task_id || '').slice(0, 8) || '?';
const tail = d.summary ? `: ${String(d.summary).slice(0, 80)}` : '';
showToast(`Task ${tid} done${tail}`, 2600);
} catch (_) {}
}
// Fire-and-forget ack (diagnostic only — Option Z made this a no-op for
// state. The agent wakeup is now started SERVER-SIDE by the drain thread
// in api/background_process._process_one → start_session_turn; the
// browser is no longer in the wakeup path at all.)
try {
fetch(_apiUrl('api/bg-task-complete-ack'), {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify({session_id: sid, task_id: pid, event_id: evt_id}),
}).catch(() => {});
} catch(_) {}
// Option Z PIVOT: the browser NO LONGER re-POSTs the chat-start endpoint
// to wake the agent. Server-side wakeup is the PRIMARY mechanism — the
// drain thread starts the turn directly (no tab required), so the
// closed-tab case works (parity with CLI/Telegram). The per-session SSE
// channel this handler is wired into is DEMOTED to a pure live-view
// layer: if a tab is open the server-initiated turn streams live via the
// existing chat-stream EventSource; if the tab is closed the turn still
// runs server-side and the result is persisted to the session store.
// The user-facing toast + drop-when-focused gate land in PR (c).
} catch(_) {}
}
// ── Clarify polling ──
let _clarifyPollTimer = null;
let _clarifyHideTimer = null;
let _clarifyVisibleSince = 0;
let _clarifySignature = '';
let _clarifySessionId = null;
let _clarifyId = null;
let _clarifyMissingEndpointWarned = false;
let _clarifyCountdownTimer = null;
let _clarifyExpiresAt = 0;
let _clarifyPendingBySession = new Map();
const CLARIFY_MIN_VISIBLE_MS = 30000;
function _clarifyPromptBelongsToActiveSession(sid) {
return !!(sid && _promptActiveSessionId() === sid);
}
function _rememberClarifyPending(pending) {
if (!pending) return null;
const sid = pending._session_id || _promptActiveSessionId();
if (!sid) return null;
const nextPending = {...pending, _session_id: sid};
_clarifyPendingBySession.set(sid, {pending: nextPending});
return sid;
}
function _clearClarifyPendingForSession(sid) {
if (sid) {
_clarifyPendingBySession.delete(sid);
if (typeof syncTopbar === 'function') syncTopbar();
}
}
function _hideClarifyCardIfOwner(sid, force=false, reason="dismissed") {
if (!sid || _clarifySessionId === sid) hideClarifyCard(force, reason);
}
function _renderPendingClarifyForActiveSession() {
const sid = _promptActiveSessionId();
if (!sid) return;
if (_clarifySessionId && _clarifySessionId !== sid) hideClarifyCard(true, 'session');
const entry = _clarifyPendingBySession.get(sid);
if (entry) showClarifyCard(entry.pending);
}
function showClarifyForSession(sid, pending) {
if (!pending) return;
pending._session_id = sid;
showClarifyCard(pending);
}
function _renderPendingPromptsForActiveSession() {
const sid = _promptActiveSessionId();
_renderPendingApprovalForActiveSession();
_renderPendingClarifyForActiveSession();
if (
sid &&
typeof activeSessionHasPendingPromptAttention === 'function' &&
activeSessionHasPendingPromptAttention()
) return;
if (typeof syncTopbar === 'function') syncTopbar();
}
function _ensureClarifyCardDom() {
let card = $("clarifyCard");
if (card) return card;
const host = $("msgInner") || $("messages");
if (!host) return null;
card = document.createElement("div");
card.className = "clarify-card";
card.id = "clarifyCard";
card.setAttribute("role", "dialog");
card.setAttribute("aria-labelledby", "clarifyHeading");
card.setAttribute("aria-describedby", "clarifyQuestion clarifyHint");
_setPromptFlyoutHidden(card, true);
card.innerHTML = `
<div class="clarify-inner">
<div class="clarify-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
<span class="clarify-countdown" id="clarifyCountdown"></span>
<button type="button" class="clarify-collapse" id="clarifyCollapse" aria-expanded="true" aria-label="Collapse clarification" aria-controls="clarifyQuestion clarifyChoices clarifyInput clarifyHint" onclick="toggleClarifyCardCollapsed()" title="Collapse clarification"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
</div>
<div class="clarify-question" id="clarifyQuestion"></div>
<div class="clarify-choices" id="clarifyChoices"></div>
<div class="clarify-response">
<input class="clarify-input" id="clarifyInput" type="text" autocomplete="off" readonly onfocus="this.removeAttribute('readonly')" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
<button class="clarify-submit" id="clarifySubmit" data-i18n="clarify_send">Send</button>
</div>
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Please choose one option, or type your own response below.</div>
</div>
`;
host.appendChild(card);
const submit = $("clarifySubmit");
if (submit) submit.onclick = () => respondClarify();
const collapse = $("clarifyCollapse");
if (collapse) collapse.onclick = () => toggleClarifyCardCollapsed();
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
return card;
}
function _syncClarifyCollapseButton(card) {
const collapse = $("clarifyCollapse");
if (!collapse || !card) return;
const collapsed = card.classList.contains("collapsed");
collapse.setAttribute("aria-expanded", collapsed ? "false" : "true");
// Icon swap: chevron-down when expanded (click to collapse), chevron-up when collapsed (click to expand)
const polyline = collapse.querySelector("svg polyline");
if (polyline) polyline.setAttribute("points", collapsed ? "18 15 12 9 6 15" : "6 9 12 15 18 9");
const label = collapsed ? "Expand clarification" : "Collapse clarification";
collapse.setAttribute("aria-label", label);
collapse.title = label;
}
let _clarifyResizeListenerReady = false;
function _clarifyMessagesNearBottom(messages) {
if (!messages) return false;
return messages.scrollHeight - messages.scrollTop - messages.clientHeight < 150;
}
function _syncClarifyTranscriptSpace(card, opts) {
opts = opts || {};
const messages = $("messages");
if (!messages) return;
const wasNearBottom = _clarifyMessagesNearBottom(messages);
if (!card || !card.classList.contains("visible")) {
messages.classList.remove("clarify-open");
messages.classList.remove("clarify-collapsed");
messages.style.removeProperty("--clarify-card-height");
messages.style.removeProperty("--clarify-dock-height");
if (wasNearBottom && typeof scrollToBottom === "function" && typeof requestAnimationFrame === "function") {
requestAnimationFrame(scrollToBottom);
}
return;
}
const collapsed = card.classList.contains("collapsed");
messages.classList.add("clarify-open");
messages.classList.toggle("clarify-collapsed", collapsed);
const measure = () => {
if (!card.classList.contains("visible")) return;
const target = collapsed ? card : (card.querySelector(".clarify-inner") || card);
const h = target && target.getBoundingClientRect().height;
if (h > 0) {
messages.style.setProperty(collapsed ? "--clarify-dock-height" : "--clarify-card-height", Math.ceil(h + 24) + "px");
}
if (wasNearBottom && typeof scrollToBottom === "function") scrollToBottom();
};
if (opts.immediate) measure();
if (typeof requestAnimationFrame === "function") requestAnimationFrame(measure);
setTimeout(measure, 420);
}
function _ensureClarifyResizeListener() {
if (_clarifyResizeListenerReady || typeof window === "undefined") return;
_clarifyResizeListenerReady = true;
window.addEventListener("resize", () => {
const card = $("clarifyCard");
if (card && card.classList.contains("visible")) {
_syncClarifyTranscriptSpace(card, {immediate: true});
}
}, {passive: true});
}
function toggleClarifyCardCollapsed(forceCollapsed) {
const card = $("clarifyCard");
if (!card) return;
const collapsed = typeof forceCollapsed === "boolean" ? forceCollapsed : !card.classList.contains("collapsed");
card.classList.toggle("collapsed", collapsed);
_syncClarifyCollapseButton(card);
_syncClarifyTranscriptSpace(card, {immediate: true});
}
function _clearClarifyHideTimer() {
if (_clarifyHideTimer) {
clearTimeout(_clarifyHideTimer);
_clarifyHideTimer = null;
}
}
function _clearClarifyCountdownTimer() {
if (_clarifyCountdownTimer) {
clearInterval(_clarifyCountdownTimer);
_clarifyCountdownTimer = null;
}
_clarifyExpiresAt = 0;
const countdown = $("clarifyCountdown");
if (countdown) {
countdown.textContent = "";
countdown.classList.remove("urgent");
}
}
function _clarifyExpiryMs(pending) {
const expiresAt = Number(pending && pending.expires_at);
if (Number.isFinite(expiresAt) && expiresAt > 0) return expiresAt * 1000;
const requestedAt = Number(pending && pending.requested_at);
const timeoutSeconds = Number(pending && pending.timeout_seconds);
if (Number.isFinite(requestedAt) && Number.isFinite(timeoutSeconds) && timeoutSeconds > 0) {
return (requestedAt + timeoutSeconds) * 1000;
}
return 0;
}
function _updateClarifyCountdown() {
const countdown = $("clarifyCountdown");
if (!countdown || !_clarifyExpiresAt) return;
const remaining = Math.max(0, Math.ceil((_clarifyExpiresAt - Date.now()) / 1000));
countdown.textContent = `${remaining}s`;
countdown.classList.toggle("urgent", remaining <= 10);
}
function _startClarifyCountdown(pending) {
const expiresAt = _clarifyExpiryMs(pending);
if (_clarifyCountdownTimer && _clarifyExpiresAt === expiresAt) return;
_clearClarifyCountdownTimer();
_clarifyExpiresAt = expiresAt;
if (!_clarifyExpiresAt) return;
_updateClarifyCountdown();
_clarifyCountdownTimer = setInterval(_updateClarifyCountdown, 1000);
}
function _stashClarifyDraft(reason) {
if (reason !== "expired" && reason !== "terminal") return false;
const submit = $("clarifySubmit");
if (submit && submit.classList.contains("loading")) return false;
const input = $("clarifyInput");
const draft = String((input && input.value) || "").trim();
if (!draft) return false;
const sid = _clarifySessionId || (S.session && S.session.session_id) || "unknown";
const key = `hermes-clarify-draft-${sid}-${_clarifySignature || "unknown"}`;
try {
sessionStorage.setItem(key, JSON.stringify({
draft,
reason,
saved_at: Date.now(),
}));
} catch (_) {}
const composer = $('msg');
if (composer) {
const current = String(composer.value || "");
composer.value = current.trim() ? `${current.replace(/\s+$/, "")}\n\n${draft}` : draft;
if (typeof autoResize === "function") autoResize();
if (typeof updateSendBtn === "function") updateSendBtn();
}
const notice = reason === "expired"
? "Clarification timed out. Your draft was kept in the composer."
: "Clarification closed. Your draft was kept in the composer.";
if (typeof setComposerStatus === "function") setComposerStatus(notice);
else if (typeof setStatus === "function") setStatus(notice);
if (typeof showToast === "function") showToast(notice, 5000);
return true;
}
function _resetClarifyCardState() {
_clearClarifyHideTimer();
_clearClarifyCountdownTimer();
_clarifyVisibleSince = 0;
_clarifySignature = '';
_clarifyId = null;
}
function hideClarifyCard(force=false, reason="dismissed") {
const card = $("clarifyCard");
if (!card) {
_clarifySessionId = null;
_resetClarifyCardState();
if (typeof unlockComposerForClarify === "function") unlockComposerForClarify();
return;
}
if (!force && reason !== "expired" && _clarifyVisibleSince) {
const remaining = CLARIFY_MIN_VISIBLE_MS - (Date.now() - _clarifyVisibleSince);
if (remaining > 0) {
const scheduledSignature = _clarifySignature;
_clearClarifyHideTimer();
_clarifyHideTimer = setTimeout(() => {
_clarifyHideTimer = null;
if (_clarifySignature !== scheduledSignature) return;
hideClarifyCard(true, reason);
}, remaining);
return;
}
}
_stashClarifyDraft(reason);
_clarifySessionId = null;
_resetClarifyCardState();
card.classList.remove("visible");
_setPromptFlyoutHidden(card, true);
_syncClarifyTranscriptSpace(null);
if (typeof unlockComposerForClarify === "function") unlockComposerForClarify();
$("clarifyQuestion").textContent = "";
$("clarifyChoices").innerHTML = "";
$("clarifyInput").value = "";
$("clarifyInput").disabled = false;
$("clarifyInput").onkeydown = null;
const submit = $("clarifySubmit");
if (submit) { submit.disabled = false; submit.classList.remove("loading"); }
}
function _clarifySetControlsDisabled(disabled, loading=false) {
const input = $("clarifyInput");
const submit = $("clarifySubmit");
if (input) input.disabled = disabled;
if (submit) {
submit.disabled = disabled;
submit.classList.toggle("loading", !!loading);
}
const choices = $("clarifyChoices");
if (choices) {
choices.querySelectorAll("button").forEach(btn => {
btn.disabled = disabled;
if (loading && btn.dataset && btn.dataset.choice === "other") {
btn.classList.toggle("loading", false);
}
});
}
}
function showClarifyCard(pending) {
const sid = _rememberClarifyPending(pending);
if (!_clarifyPromptBelongsToActiveSession(sid)) return;
const question = pending.question || pending.description || '';
const choices = Array.isArray(pending.choices_offered)
? pending.choices_offered
: (Array.isArray(pending.choices) ? pending.choices : []);
const sig = JSON.stringify({
question,
choices,
sid: pending._session_id || (S.session && S.session.session_id) || null,
clarify_id: pending.clarify_id || null,
});
const card = _ensureClarifyCardDom();
if (!card) return;
const questionEl = $("clarifyQuestion");
const choicesEl = $("clarifyChoices");
const input = $("clarifyInput");
const sameClarify = card.classList.contains("visible") && _clarifySignature === sig;
_clarifySessionId = sid;
_clarifyId = pending.clarify_id || null;
_clarifySignature = sig;
if (Number(pending.timeout_seconds) > 0) {
_startClarifyCountdown(pending);
} else {
_clearClarifyCountdownTimer();
}
if (!sameClarify) {
_clarifyVisibleSince = Date.now();
_clearClarifyHideTimer();
card.classList.remove("collapsed");
}
if (questionEl) questionEl.textContent = question;
if (choicesEl) {
choicesEl.innerHTML = '';
choicesEl.style.display = choices.length ? '' : 'none';
if (choices.length) {
choices.forEach((choice, idx) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'clarify-choice';
btn.dataset.choice = choice;
btn.onclick = () => respondClarify(choice);
const badge = document.createElement('span');
badge.className = 'clarify-choice-badge';
badge.textContent = String(idx + 1);
const text = document.createElement('span');
text.className = 'clarify-choice-text';
text.textContent = choice;
btn.appendChild(badge);
btn.appendChild(text);
choicesEl.appendChild(btn);
});
const other = document.createElement('button');
other.type = 'button';
other.className = 'clarify-choice other';
other.dataset.choice = 'other';
other.setAttribute('data-i18n', 'clarify_other');
const otherBadge = document.createElement('span');
otherBadge.className = 'clarify-choice-badge other';
otherBadge.textContent = '•';
const otherText = document.createElement('span');
otherText.className = 'clarify-choice-text';
otherText.textContent = t('clarify_other') || 'Other';
other.appendChild(otherBadge);
other.appendChild(otherText);
other.onclick = () => {
const el = $("clarifyInput");
if (el) {
el.focus();
if (typeof el.select === 'function') el.select();
}
};
choicesEl.appendChild(other);
}
}
if (input) {
if (!sameClarify) input.value = '';
input.disabled = false;
input.removeAttribute('readonly');
input.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
respondClarify();
}
};
}
if (typeof lockComposerForClarify === "function") {
lockComposerForClarify(question ? `Clarification needed: ${question}` : "Clarification needed");
}
_clarifySetControlsDisabled(false, false);
_ensureClarifyResizeListener();
_setPromptFlyoutHidden(card, false);
card.classList.add("visible");
_syncClarifyCollapseButton(card);
_syncClarifyTranscriptSpace(card, {immediate: true});
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
// Move focus to clarify input synchronously (not in setTimeout) and
// only if the user wasn't mid-type in the composer textarea.
if (input && !sameClarify && document.activeElement !== $('msg')) {
input.focus({preventScroll: true});
}
if (typeof syncTopbar === 'function') syncTopbar();
}
async function respondClarify(response) {
const sid = _clarifySessionId || (S.session && S.session.session_id);
if (!sid) return;
const input = $("clarifyInput");
let value = typeof response === 'string' ? response : (input ? input.value : '');
value = String(value || '').trim();
if (!value) {
if (input) input.focus();
return;
}
const clarifyId = _clarifyId;
// Keep a draft copy so we can restore the input on failure (issue #2639).
const draft = value;
_clarifySetControlsDisabled(true, true);
try {
const result = await api("/api/clarify/respond", {
method: "POST",
body: JSON.stringify({ session_id: sid, response: value, clarify_id: clarifyId || "" })
});
if (result && result.ok) {
// Only clear/hide if the visible prompt still matches what was just
// submitted. If a parallel SSE event already loaded the next queued
// prompt, erasing the session cache would leave the agent waiting
// until timeout (codex review P1, issue #2639).
if (_clarifyId === clarifyId) {
_clarifySessionId = null;
_clarifyId = null;
_clearClarifyPendingForSession(sid);
hideClarifyCard(true, 'sent');
// Echo the user's clarify choice as a visible message in the conversation
if (S.session && S.session.session_id === sid) {
S.messages.push({
role: 'user',
content: value,
_clarify_response: true,
_ts: Date.now() / 1000,
});
if (typeof renderMessages === 'function') renderMessages({preserveScroll: true});
}
}
} else {
// Stale / expired / wrong session — keep the card and draft visible.
_clarifySetControlsDisabled(false, false);
if (input) {
input.value = draft;
input.focus();
}
const errMsg = (result && result.error) || "Clarification response not accepted — the agent may have already proceeded.";
if (typeof showToast === "function") showToast(errMsg, 5000);
if (typeof setStatus === "function") setStatus(errMsg);
}
} catch(e) {
// The server returns 409 with ``stale: true`` for both genuinely-expired
// prompts and wrong-session/next-prompt-loaded races. In both cases the
// server-side ``_pending`` entry for *this* clarify_id is gone, so
// retrying it can never succeed — the prior keep-card-and-draft
// behavior left the user with a permanently 409-ing card and a locked
// composer, with no affordance to dismiss it (#4504). Treat 409 as
// terminal here, but only when the visible card still matches what we
// just submitted: mirroring the success path's ``_clarifyId === clarifyId``
// guard (codex review P1, #2639) — if a parallel poll already rendered
// the *next* queued prompt B while A's response was in flight, we must
// not tear B down on A's late 409. The SSE/poll path will re-render the
// next prompt's card from scratch via ``showClarifyCard`` either way.
if (e && e.status === 409) {
// #7710: a cross-profile refusal now also arrives as 409
// (``session_profile_mismatch``). The prompt is NOT expired — the write
// was refused because the session belongs to another profile. Treating
// it as expired would hide a live clarification card and mislabel the
// cause, so leave the card standing and report the real reason.
if (typeof _sessionProfileMismatchFromError === 'function'
&& _sessionProfileMismatchFromError(e)) {
_clarifySetControlsDisabled(false, false);
if (typeof setStatus === "function") {
setStatus("Clarify: session belongs to a different profile");
}
return;
}
if (_clarifyId === clarifyId) {
// Same card still showing — dismiss it and rescue the typed draft.
// Order matters: ``_stashClarifyDraft`` (called from
// ``hideClarifyCard``) bails when ``#clarifySubmit`` still carries
// the ``loading`` class set above. Clear loading first, otherwise
// the typed answer is silently dropped (reviewer P1).
_clarifySetControlsDisabled(false, false);
_clarifySessionId = null;
_clarifyId = null;
_clearClarifyPendingForSession(sid);
hideClarifyCard(true, "expired");
const errMsg = (e.message || "Clarification prompt expired or not found.");
if (typeof setStatus === "function") setStatus("Clarify: " + errMsg);
// ``_stashClarifyDraft('expired')`` already surfaces the actionable
// "Clarification timed out. Your draft was kept in the composer."
// toast when there is a draft to rescue, so we don't double-toast.
return;
}
// A newer prompt is showing (race between user click and SSE/poll).
// Don't dismiss it on this late 409 — just re-enable controls and
// surface the error. The user's draft for the now-stale prompt is
// dropped intentionally; the next prompt has its own input cycle.
_clarifySetControlsDisabled(false, false);
if (typeof setStatus === "function") {
setStatus("Clarify: previous prompt expired — a newer one is showing.");
}
return;
}
// Network / other transient errors — keep the card and draft visible so
// the user can retry once connectivity returns.
_clarifySetControlsDisabled(false, false);
if (input) {
input.value = draft;
input.focus();
}
const errMsg = (e && e.message) || "Failed to deliver clarification response.";
if (typeof setStatus === "function") setStatus("Clarify: " + errMsg);
if (typeof showToast === "function") showToast(errMsg, 5000);
}
}
var _clarifyEventSource = null;
var _clarifyFallbackTimer = null;
var _clarifyHealthTimer = null;
let _clarifyFallbackPollInFlight = false;
let _clarifyPollingSessionId = null;
function startClarifyPolling(sid) {
stopClarifyPolling();
_clarifyPollingSessionId = sid || null;
_clarifyMissingEndpointWarned = false;
// Use HTTP polling instead of SSE to avoid browser connection pool exhaustion.
// Browsers limit to 6 concurrent HTTP connections per origin over HTTP/1.1.
// With 6 persistent SSE streams (sessions/events, gateway/stream,
// session/stream, approval/stream, clarify/stream, chat/stream), the pool
// fills and all fetch() requests queue indefinitely. The server responds
// normally (curl works), but the browser has no available sockets.
//
// This was introduced in v0.51.340 when /api/session/stream was added as
// the 6th persistent SSE connection. Until we multiplex streams or serve
// SSE from a separate origin, use HTTP polling to free 2 connection slots.
// (3-second interval, acceptable tradeoff)
_startClarifyFallbackPoll(sid);
}
function _startClarifyFallbackPoll(sid) {
_clarifyPollingSessionId = sid || null;
// Run one tick immediately so a session already blocked on a pending clarify
// shows its card instantly (the removed SSE 'initial' event used to do this);
// then poll on the 3000ms cadence. (#3913 SHOULD-FIX)
const _tick = async () => {
if (!S.session || S.session.session_id !== sid) {
stopClarifyPolling(); _hideClarifyCardIfOwner(sid, true, 'session'); return;
}
if (_clarifyFallbackPollInFlight) return;
_clarifyFallbackPollInFlight = true;
try {
const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
if (data.pending) { showClarifyForSession(sid, data.pending); }
else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); }
} catch(e) {
const msg = String((e && e.message) || "");
// `api()` attaches the raw HTTP status on the thrown Error (err.status).
// Branch on that structured value instead of scraping the message string
// so an unrelated stale-session or lifecycle error can never masquerade as
// a missing clarify endpoint. (#5345)
const status = (e && typeof e.status === "number") ? e.status : null;
const currentSid = (S.session && S.session.session_id) || null;
const logDetails = {
path: "/api/clarify/pending",
status: status,
pollingSessionId: sid,
currentSessionId: currentSid,
message: msg,
};
// A 404 from the active session domain is a STALE-SESSION signal — e.g.
// the old profile's session still polling briefly after a profile switch,
// or a session deleted server-side — NOT a missing clarify endpoint. Stop
// this stale poll and hide its card silently instead of telling the user
// to restart the server. Keep this branch before any warn-level logging:
// routine profile switches must not fill DevTools with expected warnings.
// (#5343 / #5345)
const isSessionScoped404 = status === 404
&& (/session/i.test(msg) || (currentSid !== null && currentSid !== sid));
if (isSessionScoped404) {
_clearClarifyPendingForSession(sid);
_hideClarifyCardIfOwner(sid, true, 'session');
stopClarifyPolling();
return;
}
// Only a GENUINE missing-endpoint 404 (the route-not-found fall-through,
// body {"error":"not found"}, from a server build that predates
// /api/clarify/pending) should surface the restart-server warning. The
// previous code matched arbitrary "404"/"not found" text in ANY caught
// error message, so an unrelated stale-session 404 or a transient network
// error produced a false "Clarify endpoint unavailable" toast even though
// the endpoint is present and returning HTTP 200 on every request. Gate
// strictly on the structured status + a route-not-found body that is not
// a session-scoped 404. (#5345)
const isMissingEndpoint = status === 404
&& /(^|\b)not\s+found(\b|$)/i.test(msg)
&& !isSessionScoped404;
if (isMissingEndpoint) {
if (!_clarifyMissingEndpointWarned) {
_clarifyMissingEndpointWarned = true;
setComposerStatus("Clarify unavailable on current server build. Restart server.");
if (typeof showToast === "function") {
showToast("Clarify endpoint unavailable. Please restart server.", 5000);
}
if (typeof console !== "undefined" && console.warn) {
console.warn("[clarify] pending poll endpoint unavailable", logDetails);
}
}
stopClarifyPolling();
return;
}
// Structured diagnostics: unexpected clarify poll failures should be
// inspectable without guessing from a toast. Expected stale-session 404s
// and the handled missing-endpoint route have already returned above.
if (typeof console !== "undefined" && console.warn) {
console.warn("[clarify] pending poll failed", logDetails);
}
} finally {
_clarifyFallbackPollInFlight = false;
}
};
_clarifyFallbackTimer = setInterval(_tick, 3000);
_tick();
}
function stopClarifyPollingForSession(sid) {
if(sid && _clarifyPollingSessionId && _clarifyPollingSessionId!==sid) return;
stopClarifyPolling();
}
function stopClarifyPolling() {
if (_clarifyEventSource) { try { if(_clarifyEventSource.readyState!==2)_clarifyEventSource.close(); } catch(_){} _clarifyEventSource = null; }
if (_clarifyFallbackTimer) { clearInterval(_clarifyFallbackTimer); _clarifyFallbackTimer = null; }
if (_clarifyHealthTimer) { clearInterval(_clarifyHealthTimer); _clarifyHealthTimer = null; }
_clarifyFallbackPollInFlight = false;
_clarifyPollingSessionId = null;
}
// ── Notifications and Sound ──────────────────────────────────────────────────
function _completionNotificationPreviewText(lastAssistantMessage, options){
const opts=(options&&typeof options==='object')?options:{};
const sessionId=String(opts.sessionId||'').trim();
let text='';
if(lastAssistantMessage&&typeof lastAssistantMessage==='object'){
if(typeof _assistantTurnAnchorSettledFinalAnswer==='function'){
const anchorFinal=_assistantTurnAnchorSettledFinalAnswer(
lastAssistantMessage,
lastAssistantMessage.content,
{session_id:sessionId||undefined}
);
if(anchorFinal!==null&&anchorFinal!==undefined) text=String(anchorFinal||'').trim();
}
if(!text&&typeof msgContent==='function') text=String(msgContent(lastAssistantMessage)||'').trim();
if(!text){
let raw=lastAssistantMessage.content||'';
if(Array.isArray(raw)) raw=raw.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('').trim();
text=String(raw||'').trim();
}
if(text&&typeof _extractInlineThinkingFromContent==='function'){
const split=_extractInlineThinkingFromContent(text, lastAssistantMessage.reasoning, {streaming:false});
if(split&&typeof split.content==='string') text=split.content.trim();
}
}
if(!text&&typeof opts.liveDisplayText==='string') text=opts.liveDisplayText.trim();
if(!text) return '';
const normalized=text.replace(/\s+/g,' ').trim();
return normalized.length>100?`${normalized.slice(0,100)}…`:normalized;
}
function playNotificationSound(){
if(!window._soundEnabled) return;
try{
const ctx=new (window.AudioContext||window.webkitAudioContext)();
const osc=ctx.createOscillator();
const gain=ctx.createGain();
osc.connect(gain);gain.connect(ctx.destination);
osc.type='sine';osc.frequency.setValueAtTime(660,ctx.currentTime);
osc.frequency.setValueAtTime(880,ctx.currentTime+0.1);
gain.gain.setValueAtTime(0.3,ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01,ctx.currentTime+0.3);
osc.start(ctx.currentTime);osc.stop(ctx.currentTime+0.3);
osc.onended=()=>ctx.close();
}catch(e){console.warn('Notification sound failed:',e);}
}
function _attentionSoundKey(sid,kind,count){
const safeSid=String(sid||'');
const safeKind=String(kind||'attention');
const safeCount=Math.max(1,Number(count)||1);
return `${safeSid}:${safeKind}:${safeCount}`;
}
function playAttentionSound(key){
if(!window._soundEnabled) return;
const nowMs=Date.now();
if(window._lastAttentionSoundAt&&nowMs-window._lastAttentionSoundAt<900) return;
const dedupeKey=key?String(key):'';
if(dedupeKey){
const seen=window._attentionSoundSeenKeys instanceof Map?window._attentionSoundSeenKeys:new Map();
window._attentionSoundSeenKeys=seen;
for(const [seenKey,seenAt] of seen){
if(nowMs-Number(seenAt||0)>300000) seen.delete(seenKey);
}
if(seen.has(dedupeKey)) return;
seen.set(dedupeKey,nowMs);
}
window._lastAttentionSoundAt=nowMs;
try{
const ctx=new (window.AudioContext||window.webkitAudioContext)();
const osc=ctx.createOscillator();
const gain=ctx.createGain();
osc.connect(gain);gain.connect(ctx.destination);
osc.type='sine';osc.frequency.setValueAtTime(880,ctx.currentTime);
osc.frequency.setValueAtTime(660,ctx.currentTime+0.075);
gain.gain.setValueAtTime(0.24,ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01,ctx.currentTime+0.24);
osc.start(ctx.currentTime);osc.stop(ctx.currentTime+0.24);
osc.onended=()=>ctx.close();
}catch(e){console.warn('Attention sound failed:',e);}
}
function _notificationOptions(body,options={}){
const sid=(options&&options.sid)||(S&&S.session&&S.session.session_id);
const url=sid?`${location.origin}${_sessionUrlForSid(sid)}`:location.href;
return {body:body||'',tag:sid?`hermes-${sid}`:'hermes-webui',renotify:true,icon:'static/favicon-192.png',badge:'static/favicon-32.png',data:{url}};
}
function _showPwaNotification(title,body,options={}){
const botName=assistantDisplayName();
const opts=_notificationOptions(body,options);
const direct=()=>new Notification(title||botName,opts);
// Prefer the service worker (the only path that works in a standalone PWA,
// notably iOS). Use getRegistration() + a short timeout race rather than
// navigator.serviceWorker.ready, because `.ready` NEVER settles when no
// registration ever activates for the scope (e.g. a reverse proxy serving
// sw.js with the wrong MIME type, or SW disabled in the browser) — which
// would silently drop every notification instead of falling back.
if(navigator.serviceWorker&&navigator.serviceWorker.getRegistration){
const reg$=Promise.race([
navigator.serviceWorker.getRegistration().catch(()=>null),
new Promise(res=>setTimeout(()=>res(null),2000))
]);
return reg$.then(reg=>(reg&&reg.active&&reg.showNotification)
? reg.showNotification(title||botName,opts)
: direct());
}
return Promise.resolve(direct());
}
function requestNotificationPermission(){
if(!('Notification' in window)){
if(typeof showToast==='function') showToast(t('notifications_unsupported'),3000,'error');
if(typeof updateNotificationPermissionStatus==='function') updateNotificationPermissionStatus();
return Promise.resolve('unsupported');
}
if(Notification.permission==='granted'){
if(typeof updateNotificationPermissionStatus==='function') updateNotificationPermissionStatus();
if(typeof showToast==='function') showToast(t('notifications_enabled_toast'),3000);
return Promise.resolve('granted');
}
if(Notification.permission==='denied'){
if(typeof showToast==='function') showToast(t('notifications_denied'),3500,'error');
if(typeof updateNotificationPermissionStatus==='function') updateNotificationPermissionStatus();
return Promise.resolve('denied');
}
return Notification.requestPermission().then(p=>{
if(typeof showToast==='function') showToast(p==='granted'?t('notifications_enabled_toast'):t('notifications_denied'),3000,p==='granted'?undefined:'error');
if(typeof updateNotificationPermissionStatus==='function') updateNotificationPermissionStatus();
return p;
});
}
function sendBrowserNotification(title,body,options={}){
const force=!!(options&&options.force);
// #4416: `forceHidden` means the caller already determined the tab was hidden
// during the relevant window (e.g. a stream that ran while backgrounded), so
// the live `document.hidden` visibility gate — which a late, throttled SSE
// makes unreliable — should be treated as satisfied. The user's
// notifications-enabled SETTING is still honored (unlike `force`, which is the
// explicit "Send test" override); only the visibility gate is bypassed.
const forceHidden=!!(options&&options.forceHidden);
if(!force&&!window._notificationsEnabled) return;
if(!force&&!forceHidden&&!_isBackgroundedForBrowserNotification()) return;
if(!('Notification' in window)) return;
if(Notification.permission==='granted'){
_showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}});
}else if(Notification.permission==='denied'){
// Explicit "Send test" (force) deserves feedback instead of a silent no-op.
if(force&&typeof showToast==='function') showToast(t('notifications_denied'),3500,'error');
}else{
requestNotificationPermission().then(p=>{if(p==='granted') _showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}});});
}
}
// ── /btw ephemeral stream ────────────────────────────────────────────────────
// Connects to the ephemeral SSE stream from /api/btw and renders the answer
// in a visually distinct bubble that is NOT persisted to session history.
function attachBtwStream(parentSid, streamId, question){
if(!parentSid||!streamId) return;
const src=new EventSource(new URL('api/chat/stream?stream_id='+encodeURIComponent(streamId), document.baseURI||location.href).href);
let answer='';
let btwRow=null;
let _streamDone=false;
function _ensureBtwRow(){
if(btwRow&&btwRow.isConnected) return;
const inner=$('msgInner');
if(!inner) return;
btwRow=document.createElement('div');
btwRow.className='msg-row msg-row-btw';
btwRow.dataset.role='assistant';
btwRow.dataset.btw='1';
const labelEl=document.createElement('div');
labelEl.className='msg-btw-label';
labelEl.textContent=t('btw_label');
const qEl=document.createElement('div');
qEl.className='msg-body';
qEl.textContent=question;
const ansEl=document.createElement('div');
ansEl.className='msg-body msg-btw-answer';
ansEl.textContent='...';
btwRow.appendChild(labelEl);
btwRow.appendChild(qEl);
btwRow.appendChild(ansEl);
inner.appendChild(btwRow);
btwRow.scrollIntoView({behavior:'smooth',block:'end'});
}
src.addEventListener('token',e=>{
try{answer+=JSON.parse(e.data).text||'';}catch(_){}
_ensureBtwRow();
const ansEl=btwRow&&btwRow.querySelector('.msg-btw-answer');
if(ansEl) ansEl.innerHTML=renderMd(answer);
});
src.addEventListener('done',e=>{
_streamDone=true;
src.close();
try{
const d=JSON.parse(e.data);
if(d.answer&&!answer) answer=d.answer;
}catch(_){}
if(S.session&&S.session.session_id===parentSid) _ensureBtwRow();
if(btwRow&&btwRow.isConnected){
const ansEl=btwRow.querySelector('.msg-btw-answer');
if(ansEl) ansEl.innerHTML=renderMd(answer||t('btw_no_answer'));
}
showToast(t('btw_done'));
});
src.addEventListener('apperror',e=>{
_streamDone=true;
src.close();
try{
const d=JSON.parse(e.data);
showToast(t('btw_failed')+(d.message||''));
}catch(_){showToast(t('btw_failed'));}
if(btwRow&&btwRow.isConnected) btwRow.remove();
});
src.addEventListener('stream_end',()=>{_streamDone=true;src.close();});
src.onerror=()=>{src.close();if(!_streamDone&&btwRow&&btwRow.isConnected) btwRow.remove();};
}
// ── /background task tracking ────────────────────────────────────────────────
let _bgPollTimers={};
let _bgActiveTasks=new Set();
function showBackgroundBadge(taskId){
_bgActiveTasks.add(taskId);
const badge=$('bgBadge');
if(badge){
badge.textContent=String(_bgActiveTasks.size);
badge.style.display=_bgActiveTasks.size?'':'none';
}
}
function hideBackgroundBadge(taskId){
_bgActiveTasks.delete(taskId);
const badge=$('bgBadge');
if(badge){
badge.textContent=String(_bgActiveTasks.size);
badge.style.display=_bgActiveTasks.size?'':'none';
}
}
function startBackgroundPolling(parentSid, taskId, prompt){
if(_bgPollTimers[taskId]) return;
async function _poll(){
try{
const r=await api('/api/background/status?session_id='+encodeURIComponent(parentSid));
if(r&&r.results){
for(const res of r.results){
if(res.task_id===taskId){
hideBackgroundBadge(taskId);
delete _bgPollTimers[taskId];
const msg={role:'assistant',content:`**${t('bg_label')}** ${prompt.slice(0,80)}\n\n${res.answer||t('bg_no_answer')}`,'_background':true,_ts:Date.now()/1000};
S.messages.push(msg);
renderMessages({preserveScroll:true});
showToast(t('bg_complete'));
return;
}
}
}
}catch(_){}
_bgPollTimers[taskId]=setTimeout(_poll,3000);
}
_poll();
}
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──