Files
hermes-webui/static/messages.js
nesquena-hermes 26e133e3e8
Some checks failed
Release & Docker / release (push) Has been cancelled
[HELD — independent review pending] Release v0.51.340 — bg_task agent wakeup (trio #2968+#2971+#2979) (#3867)
* stage bg_task trio combined (#2979 superset) on master for deep review

* fix(bg_task): unsubscribe SessionChannel on header-write failure (Codex deep-review catch) + regression test

* test: realign on-subscribe-recovery anchor to subscribe_to_session_channel after leak fix

* CHANGELOG: bg_task trio as v0.51.340 LD (HELD pending independent review)

* bg_task trio: apply 3 independent-review (greptile) fixes

1. start_session_turn now threads the session PROFILE model defaults
   (_read_profile_model_config) into the wakeup model-resolve, so a brand-new
   session with an empty model falls back to the profile default not global
   DEFAULT_MODEL. Updated the white-box spy test signature accordingly.
2. /api/session/stream omits the Connection header (HTTP/1.1 keep-alive
   default) to match the #3103 long-lived-SSE pattern.
3. Reaper now prunes _LAST_EMIT_TS for collected sessions so the coalesce
   timestamp map can't grow one permanent entry per session forever.

nesquena APPROVED the PR; these are the 3 non-blocking greptile suggestions.

* test: realign _start_session_turn adapter stub lambda to new profile-defaults signature
2026-06-08 22:36:18 -07:00

5074 lines
227 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.
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;
}
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 _selectedTextReplyText='';
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 _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);
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){
const normalized=String(text||'').replace(/\r\n?/g,'\n').replace(/\n{3,}/g,'\n\n').trim();
if(!normalized)return '';
return normalized.split('\n').map(line=>`> ${line}`).join('\n');
}
function _appendSelectedTextReplyToComposer(text){
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
if(!composer)return false;
const quote=_formatSelectedTextReplyQuote(text);
if(!quote)return false;
const current=String(composer.value||'');
composer.value=current.trim()?`${current.replace(/\s+$/,'')}\n\n${quote}\n\n`:`${quote}\n\n`;
composer.focus();
try{ composer.setSelectionRange(composer.value.length, composer.value.length); }catch(_err){}
composer.dispatchEvent(new Event('input', {bubbles:true}));
if(typeof autoResize==='function') autoResize();
if(typeof showToast==='function') showToast(_selectedTextReplyT('selected_text_reply_appended', 'Selected text added to composer'), 1600);
return true;
}
function insertSavedPromptIntoComposer(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}\n\n`:`${text}\n\n`;
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();
}
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 _selectedTextReplyButton(){
if(_selectedTextReplyBtn)return _selectedTextReplyBtn;
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_title');
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.title);
btn.addEventListener('mousedown', e=>e.preventDefault());
btn.addEventListener('click', e=>{
e.preventDefault();
if(_appendSelectedTextReplyToComposer(_selectedTextReplyText)){
_hideSelectedTextReplyButton();
const selection=window.getSelection&&window.getSelection();
if(selection&&selection.removeAllRanges)selection.removeAllRanges();
}
});
document.body.appendChild(btn);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
_selectedTextReplyBtn=btn;
return btn;
}
function _hideSelectedTextReplyButton(){
_selectedTextReplyText='';
if(_selectedTextReplyBtn)_selectedTextReplyBtn.classList.remove('visible');
}
function _positionSelectedTextReplyButton(info){
const btn=_selectedTextReplyButton();
_selectedTextReplyText=info.text;
btn.classList.add('visible');
const gap=8;
const btnRect=btn.getBoundingClientRect();
const width=btnRect.width||150;
const height=btnRect.height||32;
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);
btn.style.left=`${left}px`;
btn.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-reply-btn'))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.
const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set(['reload-mcp', 'reload_mcp', 'codex-runtime', 'codex_runtime']);
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 _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;
}
async function send(){
// 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=$('msg').value.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'});
$('msg').value='';autoResize();
S.pendingFiles=[];renderTray();
updateQueueBadge(_targetSid);
showToast(`Queued: "${_text.slice(0,40)}${_text.length>40?'…':''}"`,2000);
}
return;
}
_sendInProgress = true;
try{
const text=$('msg').value.trim();
if(!text&&!S.pendingFiles.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;}
// 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 busy_input_mode
if(S.busy||compressionRunning){
if(text){
if(!S.session){await newSession();await renderSessionList();}
// Busy-control slash commands must be intercepted HERE, before the
// busyMode routing block, so the user can always type /steer, /interrupt,
// or /queue 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."
if(text.startsWith('/')){
const _pc=typeof parseCommand==='function'&&parseCommand(text);
if(_pc&&['steer','interrupt','queue','terminal','goal'].includes(_pc.name)){
const _bc=COMMANDS.find(c=>c.name===_pc.name);
if(_bc){
$('msg').value='';autoResize();
await _bc.fn(_pc.args);
return;
}
}
}
const busyMode=window._busyInputMode||'queue';
if(busyMode==='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 falls back to queue+cancel internally if the agent
// isn't running / cached / steer-capable.
$('msg').value='';autoResize();
// Do NOT clear pendingFiles yet — _trySteer may fall back to
// interrupt+queue and needs the files for queueSessionMessage.
// _trySteer clears pendingFiles itself in the fallback path, and
// the server returns accepted:true (no files sent) on success.
await _trySteer(text, /*explicitSteer=*/false);
// After _trySteer: clear any remaining files (success path).
S.pendingFiles=[];renderTray();
} else if(busyMode==='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);
$('msg').value='';autoResize();
S.pendingFiles=[];renderTray();
if(S.activeStreamId&&typeof cancelStream==='function'){
showToast(t('busy_interrupt_confirm'),2000);
await cancelStream();
} 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'});
$('msg').value='';autoResize();
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;
}
// 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){
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){
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(!S.session){await newSession();await renderSessionList();}
const activeSid=S.session.session_id;
_sendInProgressSid=activeSid;
setComposerStatus(S.pendingFiles&&S.pendingFiles.length?'Uploading…':'');
let uploaded=[];
try{uploaded=await uploadPendingFiles();}
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 _directive = await _pending.promise;
if(_forcedSkillDirectivePending===_pending)_forcedSkillDirectivePending = null;
if(typeof _directive==='string'&&_directive){
msgText=`${_directive}\n\n${msgText||''}`.trim();
}
}
}
if(!msgText){setComposerStatus('Nothing to send');return;}
$('msg').value='';autoResize();
// Clear persisted composer draft since message was sent.
if (activeSid && typeof _clearComposerDraft === 'function') _clearComposerDraft(activeSid);
const displayText=text||(uploaded.length?`Uploaded: ${uploadedNames.join(', ')}`:'(file upload)');
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000};
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();appendThinking('',{pending:true});setBusy(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;}
S.activeStreamId=null;
}
// Start the agent via POST, get a stream_id back
let streamId;
try{
const _modelState=_chatPayloadModelState();
const _pendingPick=(typeof _readPendingSessionModel==='function')
? _readPendingSessionModel(activeSid)
: null;
const _explicitPick=_pendingPick
&& _pendingPick.model===_modelState.model
&& String(_pendingPick.model_provider||'')===String(_modelState.model_provider||'');
// 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(_explicitPick && typeof _clearPendingSessionModel==='function') _clearPendingSessionModel(activeSid);
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
})});
if(startData.title) applySessionTitleUpdate(activeSid, startData.title, {provisionalText:displayText.slice(0,64), rememberProvisional:true});
if(startData.effective_model && S.session){
const _sentModel=_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.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();
}
streamId=startData.stream_id;
S.activeStreamId = streamId;
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&&typeof startData.pending_started_at==='number'){
S.session.pending_started_at=startData.pending_started_at;
}
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();
}
}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;
}
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}`);
if(typeof clearOptimisticSessionStreaming==='function') clearOptimisticSessionStreaming(activeSid);
// Reconcile with server truth after immediately clearing the optimistic spinner.
if(typeof renderSessionList==='function') void renderSessionList();
return;
}
// Open SSE stream and render tokens live
attachLiveStream(activeSid, streamId, uploadedNames);
}finally{ _sendInProgress=false; _sendInProgressSid=null; }
}
const LIVE_STREAMS={};
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{live.source.close();}catch(_){ }
delete LIVE_STREAMS[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,
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 attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!activeSid||!streamId) return;
const reconnecting=!!options.reconnecting;
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=[];
}
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});
}
// 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;
_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();
}
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)
&& /previous response was cut off by a network error/i.test(normalized)
&& /continue exactly where you left off/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 _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,
journalReplayFromStart:!!inflight.journalReplayFromStart,
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 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 _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){
const opts=_liveThinkingPlacement();
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;
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');
_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)) return;
if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return;
_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 _streamFadeAppendOffset=0;
let _streamFadeVisibleWords=0;
let _streamFadeHoldUntilMs=0;
let _streamFadeCurrentMs=200;
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='';
const _STREAM_FADE_MS=200;
const _STREAM_FADE_MAX_MS=350;
const _STREAM_FADE_STAGGER_MS=16;
const _STREAM_FADE_DONE_MAX_MS=320;
const _STREAM_FADE_DONE_DRAIN_MAX_MS=900;
const _streamFadeEnabledForStream=window._fadeTextEffect===true;
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.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.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;
const lo=String(s).toLowerCase();
if(lo.indexOf('function_calls')===-1 && lo.indexOf('dsml')===-1) 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.trim();
}
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) : window.smd.default_renderer(el);
const renderer=_smdRendererWithoutUnderscoreEmphasis(baseRenderer);
_smdParser=window.smd.parser(renderer);
}
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 flush remaining markdown that creates new links/images —
// re-sanitize the body before the DOM is handed off to highlightCode / renderMessages.
if(assistantBody){_sanitizeSmdLinks(assistantBody);enhanceMarkdownTables(assistantBody);}
}
_smdParser=null;
_smdWrittenLen=0;
_smdWrittenText='';
}
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||'');
// Self-heal desyncs: if displayText no longer starts with what we've 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)){
_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;
// streaming-markdown does NOT sanitize URL schemes. The default live path
// scans after writes; fade mode blocks unsafe href/src in its renderer.set_attr.
if(assistantBody&&!fade){_sanitizeSmdLinks(assistantBody);}
_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:|\/|#|\?|\.|api|session\/)/i;
const _SMD_SAFE_IMG_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.)/i;
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(!_SMD_SAFE_IMG_URL_RE.test(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;
_streamFadeAppendOffset=0;
_streamFadeVisibleWords=0;
_streamFadeHoldUntilMs=0;
_streamFadeCurrentMs=_STREAM_FADE_MS;
}
function _cancelAnimationFramePendingStreamRender(){
if(_pendingRafHandle===null) return;
cancelAnimationFrame(_pendingRafHandle);
clearTimeout(_pendingRafHandle);
_pendingRafHandle=null;
_renderPending=false;
}
function _shouldUseStreamFade(){
return _streamFadeEnabledForStream;
}
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;
span.replaceWith(document.createTextNode(span.textContent||''));
});
}
function _streamFadeRenderer(el){
_streamFadeBindCleanup(el);
const renderer=window.smd.default_renderer(el);
const baseAddText=renderer.add_text;
const baseSetAttr=renderer.set_attr;
renderer.add_text=(data,text)=>{
const parent=data&&data.nodes&&data.nodes[data.index];
if(!parent||_streamFadeSkipNode(parent)){baseAddText(data,text);return;}
const frag=document.createDocumentFragment();
const wordRe=/(\S+)(\s*)/g;
const value=String(text||'');
const reduceMotion=_streamFadeReduceMotionEnabled();
const appendStartedAt=performance.now();
let last=0, match, changed=false;
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;
}
const span=document.createElement('span');
span.className='stream-fade-word is-new';
const fadeMs=_streamFadeCurrentMs||_STREAM_FADE_MS;
const delayMs=_streamFadeAppendOffset*_STREAM_FADE_STAGGER_MS;
span.style.animationDelay=delayMs+'ms';
if(fadeMs!==_STREAM_FADE_MS) span.style.setProperty('--stream-fade-ms',fadeMs+'ms');
span.textContent=match[1];
frag.appendChild(span);
_streamFadeAppendOffset+=1;
_streamFadeLatestAnimationEndAt=Math.max(_streamFadeLatestAnimationEndAt,appendStartedAt+delayMs+fadeMs);
if(match[2]) frag.appendChild(document.createTextNode(match[2]));
last=match.index+match[0].length;
changed=true;
}
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 safeUrl=isSrc?_SMD_SAFE_IMG_URL_RE:_SMD_SAFE_URL_RE;
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)&&!safeUrl.test(String(value||''))){
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 _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. Reset safely rather than
// trying to animate across incompatible strings or stale word birth timestamps.
_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(!_smdParser&&window.smd){
if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;}
_smdNewParser(assistantBody,true);
}
if(_smdParser){
_streamFadeAppendOffset=0;
_smdWrite(next.text,true);
}else{
assistantBody.innerHTML=renderMd ? renderMd(next.text||'') : esc(next.text||'');
_sanitizeSmdLinks(assistantBody);
}
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);
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);
if(!assistantBody||(!force&&!_renderPending)) return;
if(_renderPending) _cancelAnimationFramePendingStreamRender();
const displayText=segmentStart===0
? _parseStreamState().displayText
: _stripXmlToolCalls(assistantText.slice(segmentStart));
if(_smdParser){
_smdWrite(displayText);
} else if(renderMd){
assistantBody.innerHTML=renderMd(displayText);
} else {
assistantBody.innerHTML=esc(displayText);
}
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;
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||'').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.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||'').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;
function _scheduleRender(){
if(_renderPending) return;
if(_streamFinalized) return; // Bug A: don't schedule new rAF after stream finalized
_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;
_lastRenderMs=performance.now();
const parsed=_parseStreamState();
_renderLiveThinking(parsed);
if(assistantBody){
const displayText = segmentStart===0
? parsed.displayText // first segment: uses think-tag stripping
: _stripXmlToolCalls(assistantText.slice(segmentStart));
if(_shouldUseStreamFade()){
const caughtUp=_renderStreamingFadeMarkdown(displayText);
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);
}
scrollIfPinned();
snapshotLiveTurn();
};
const frameIntervalMs=_shouldUseStreamFade()?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;
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{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);
const parsed=_parseStreamState();
if(_freshSegment) appendThinking('', _liveThinkingPlacement());
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
_scheduleRender();
});
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);
if(!visible){
return;
}
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});
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
if(typeof closeCurrentLiveActivityGroup==='function') closeCurrentLiveActivityGroup();
// 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 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;
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){
_updateLiveThinkingCard(_liveThinkingText());
}
});
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;
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;
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);
showApprovalForSession(activeSid, d, 1);
playAttentionSound(_attentionSoundKey(activeSid,'approval',1));
sendBrowserNotification('Approval required',d.description||'Tool approval needed');
});
source.addEventListener('clarify',e=>{
const d=JSON.parse(e.data);
showClarifyForSession(activeSid, d);
playAttentionSound(_attentionSoundKey(activeSid,'clarify',1));
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed');
});
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;
_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;
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;
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;}
const _doneData=JSON.parse(e.data);
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;
const isActiveSession=_isSessionCurrentPane(activeSid);
const isSessionViewed=_isSessionActivelyViewed(activeSid);
const completedSession=d.session||{session_id:activeSid};
const completedSid=completedSession.session_id||activeSid;
if(!isSessionViewed && typeof _markSessionCompletionUnread==='function'){
_markSessionCompletionUnread(completedSid, completedSession.message_count);
}
_clearOwnerInflightState();
if(typeof _markSessionCompletedInList==='function'){
_markSessionCompletedInList(completedSession, activeSid);
}
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
const shouldFollowOnDone=isActiveSession&&((typeof _shouldFollowMessagesOnDomReplace==='function')
? _shouldFollowMessagesOnDomReplace()
: (typeof _isMessagePaneNearBottom==='function'&&_isMessagePaneNearBottom(1200)));
if(isActiveSession){
S.activeStreamId=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 _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
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
){
window._compressionUi={...window._compressionUi, sessionId:d.session.session_id};
}
// Find the last assistant message once for both reasoning persistence and timestamp
const 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']){
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];
}
}
}
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 renderSessionArtifacts==='function') renderSessionArtifacts();
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,
});
}
clearLiveToolCards();
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, completedSession.message_count ?? S.messages.length);
// 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());
}
syncTopbar();renderMessages({preserveScroll:true});
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(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();
playNotificationSound();
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
};
if(_shouldUseStreamFade()&&assistantBody){
_cancelAnimationFramePendingStreamRender();
_drainStreamFadeBeforeDone(_finishDone);
return;
}
_finishDone();
});
source.addEventListener('stream_end',async e=>{
if(_streamFinalized){
_closeSource(source);
return;
}
if(_bailOutOfTerminalEventsFromStaleStream(source)) return;
_terminalStateReached=true;
try{
const d=JSON.parse(e.data||'{}');
if((d.session_id||activeSid)!==activeSid) return;
}catch(_){}
// 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.
if(await _restoreSettledSession(source)){
return;
}
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_closeSource(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;
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;
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;
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;
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_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.
source.close();
_clearOwnerInflightState();
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
let d={};
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
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(S.session&&eventMatchesCurrent){
S.activeStreamId=null;
clearLiveToolCards();if(!assistantText)removeThinking();
let isRecoveryControlMessage=false;
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';
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':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':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;
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);
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 {
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`,provider_details:details,provider_details_label:detailsLabel});
}
}catch(_){
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
}
if(isRecoveryControlMessage){
(async()=>{
if(await _restoreSettledSession(source)) 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
});
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);
// Show as a small inline notice, not a full error
setComposerStatus(`${d.message||'Warning'}`);
// If it's a fallback notice, show it briefly then clear
if(d.type==='fallback') setTimeout(()=>setComposerStatus(''),4000);
}catch(_){}
});
source.addEventListener('error',async e=>{
if(_bailOutOfTerminalEventsFromStaleStream(source) && !_streamFinalized){
return;
}
if(_terminalStateReached || _streamFinalized){
_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'});
source.close();
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 one reconnect if the stream is still active server-side
if(!_reconnectAttempted && streamId){
_reconnectAttempted=true;
setComposerStatus('Reconnecting…');
setTimeout(async()=>{
try{
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
if(st.active){
setComposerStatus('Reconnected');
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true}));
return;
}
if(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)) return;
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
_handleStreamError(source);
},1500);
return;
}
if(await _restoreSettledSession(source)) return;
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
_handleStreamError(source);
});
source.addEventListener('cancel',e=>{
if(_bailOutOfTerminalEventsFromStaleStream(source)) return;
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
source.close();
_clearOwnerInflightState();
_clearApprovalForOwner();
_clearClarifyForOwner('cancelled');
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
}
// 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
(async()=>{
try{
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
if(data&&data.session&&S.session&&S.session.session_id===activeSid){
S.session=data.session;
const _nextMsgs3018=(data.session.messages||[]).filter(m=>m&&m.role);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
clearLiveToolCards();if(!assistantText)removeThinking();
_markSessionViewed(activeSid, data.session.message_count ?? S.messages.length);
renderMessages({preserveScroll:true});
}
}catch(_){
// Fallback to local cancel message if API fails
if(S.session&&S.session.session_id===activeSid){
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});renderMessages({preserveScroll:true});
_markSessionViewed(activeSid, S.messages.length);
}
}
})();
renderSessionList();
_setActivePaneIdleIfOwner();
});
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'];
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(pm[f]!=null && nm[f]==null) nm[f]=pm[f];
}
}
return nextMessages;
}
if(typeof window!=='undefined'){
window._carryForwardEphemeralTurnFields=_carryForwardEphemeralTurnFields;
}
async function _restoreSettledSession(source){
if(_isActiveSession() && S.activeStreamId!==streamId){
_closeSource(source);
return 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 true;
const session=data&&data.session;
if(!session) return false;
if(session.active_stream_id||session.pending_user_message) return false;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_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);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
S.messages=_filterRecoveryControlMessages(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);
}
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(_isActiveSession()) _queueDrainSid=activeSid;
renderSessionList();
_setActivePaneIdleIfOwner();
return true;
}catch(_){
return false;
}
}
function _handleStreamError(source){
if(_isActiveSession() && S.activeStreamId!==streamId){
_closeSource(source);
return;
}
// 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;}
_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;
clearLiveToolCards();if(!assistantText)removeThinking();
S.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.'});renderMessages({preserveScroll:true});
_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();
}
(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){
S.activeStreamId=null;
clearLiveToolCards();
removeThinking();
if(_isActiveSession()) _queueDrainSid=activeSid;
_setActivePaneIdleIfOwner();
renderMessages({preserveScroll:true});
renderSessionList();
}
return;
}
}catch(_){}
}
const replayParams=(reconnecting||replayOnly)?_runJournalReplayParams():'';
_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');
}
function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';updateSendBtn();}
// ── 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 sid = S.session && S.session.session_id;
if (!sid) return;
try {
await api('/api/session/yolo', {
method: 'POST',
body: JSON.stringify({ session_id: sid, enabled: true }),
});
_yoloEnabled = true;
_updateYoloPill();
hideApprovalCard(true);
showToast(t('yolo_enabled'));
} catch (e) { showToast('YOLO: ' + e.message); }
}
// ── 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 _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;
}
}
_approvalSessionId = null;
_resetApprovalCardState();
card.classList.remove("visible");
card.classList.remove("collapsed");
_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();
function _promptActiveSessionId() {
return (S.session && S.session.session_id) || null;
}
function _approvalPromptBelongsToActiveSession(sid) {
return !!(sid && _promptActiveSessionId() === 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);
}
function _hideApprovalCardIfOwner(sid, force=false) {
if (!sid || _approvalSessionId === sid) hideApprovalCard(force);
}
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 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;
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});
const card = $("approvalCard");
const sameApproval = card.classList.contains("visible") && _approvalSignature === sig;
$("approvalDesc").textContent = desc;
$("approvalCmd").textContent = cmd;
_approvalSessionId = sid;
_approvalCurrentId = pending.approval_id || null;
_approvalSignature = sig;
// Show "1 of N" counter when multiple approvals are queued
const counter = $("approvalCounter");
if (counter) {
if (pendingCount && pendingCount > 1) {
counter.textContent = "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");
}
// Re-enable buttons in case a previous approval disabled them
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny"].forEach(id => {
const b = $(id); if (b) { b.disabled = false; b.classList.remove("loading"); }
});
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);
}
}
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 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) {
const sid = _approvalSessionId || (S.session && S.session.session_id);
if (!sid) return;
const approvalId = _approvalCurrentId;
// Disable all buttons immediately to prevent double-submit
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny"].forEach(id => {
const b = $(id);
if (b) { b.disabled = true; if (b.id === "approvalBtn" + choice.charAt(0).toUpperCase() + choice.slice(1)) b.classList.add("loading"); }
});
_approvalSessionId = null;
_approvalCurrentId = null;
_clearApprovalPendingForSession(sid);
hideApprovalCard(true);
try {
await api("/api/approval/respond", {
method: "POST",
body: JSON.stringify({ session_id: sid, choice, approval_id: approvalId })
});
} catch(e) { setStatus(t("approval_responding") + " " + e.message); }
}
function startApprovalPolling(sid) {
stopApprovalPolling();
_approvalPollingSessionId = sid || null;
// ── SSE (preferred): long-lived connection, server pushes instantly ──
try {
const es = new EventSource(new URL('api/approval/stream?session_id=' + encodeURIComponent(sid), document.baseURI || location.href).href);
let _fallbackActive = false;
es.addEventListener('initial', e => {
const d = JSON.parse(e.data);
if (d.pending) { showApprovalForSession(sid, d.pending, d.pending_count || 1); }
else { _clearApprovalPendingForSession(sid); _hideApprovalCardIfOwner(sid); }
});
es.addEventListener('approval', e => {
const d = JSON.parse(e.data);
if (d.pending) { showApprovalForSession(sid, d.pending, d.pending_count || 1); }
else { _clearApprovalPendingForSession(sid); _hideApprovalCardIfOwner(sid); }
});
es.onerror = () => {
// SSE failed — fall back to HTTP polling (3s interval)
if (_fallbackActive) return;
_fallbackActive = true;
try { es.close(); } catch(_){}
_startApprovalFallbackPoll(sid);
};
// If the session changes or stops being busy, close the SSE.
// We detect this via a periodic check (cheap — no network request).
_approvalSSEHealthTimer = setInterval(() => {
if (!S.busy || !S.session || S.session.session_id !== sid) {
stopApprovalPolling(); _hideApprovalCardIfOwner(sid, true);
}
}, 5000);
_approvalEventSource = es;
} catch(_e) {
// EventSource constructor failed — use polling directly
_startApprovalFallbackPoll(sid);
}
}
let _approvalEventSource = null;
let _approvalSSEHealthTimer = null;
let _approvalPollingSessionId = null;
function _startApprovalFallbackPoll(sid) {
_approvalPollTimer = setInterval(async () => {
if (!S.busy || !S.session || S.session.session_id !== 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 { _clearApprovalPendingForSession(sid); _hideApprovalCardIfOwner(sid); }
} catch(e) { /* ignore poll errors */ }
finally { _approvalFallbackPollInFlight = false; }
}, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
}
function stopApprovalPollingForSession(sid) {
if(sid && _approvalPollingSessionId && _approvalPollingSessionId!==sid) return;
stopApprovalPolling();
}
function stopApprovalPolling() {
if (_approvalPollTimer) { clearInterval(_approvalPollTimer); _approvalPollTimer = null; }
if (_approvalEventSource) { try { _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;
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;
stopSessionStream();
_sessionStreamSessionId = sid;
try {
const es = new EventSource(_apiUrl('api/session/stream?session_id=' + encodeURIComponent(sid)));
_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'});
}
});
// ── 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 updateSendBtn === 'function') updateSendBtn();
if (typeof setComposerStatus === 'function') setComposerStatus('');
if (typeof syncTopbar === 'function') syncTopbar();
if (typeof appendThinking === 'function') appendThinking();
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 { 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; }
if (_sessionEventSource) {
try { _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);
}
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() {
_renderPendingApprovalForActiveSession();
_renderPendingClarifyForActiveSession();
}
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");
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)) {
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");
_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;
_startClarifyCountdown(pending);
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();
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});
}
}
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) {
// Stale (409) or network error — keep the card and draft visible so the user can retry.
_clarifySetControlsDisabled(false, false);
if (input) {
input.value = draft;
input.focus();
}
const errMsg = (e && e.status === 409)
? (e.message || "Clarification prompt expired or not found.")
: ((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;
// SSE primary path: long-lived connection pushes events instantly.
try {
_clarifyEventSource = new EventSource(new URL('api/clarify/stream?session_id=' + encodeURIComponent(sid), document.baseURI || location.href).href);
} catch(e) {
_startClarifyFallbackPoll(sid);
return;
}
_clarifyEventSource.addEventListener('initial', function(ev) {
try {
var d = JSON.parse(ev.data);
if (d.pending) { showClarifyForSession(sid, d.pending); }
else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); }
} catch(e) {}
});
_clarifyEventSource.addEventListener('clarify', function(ev) {
try {
var d = JSON.parse(ev.data);
if (d.pending) { showClarifyForSession(sid, d.pending); }
else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); }
} catch(e) {}
});
_clarifyEventSource.onerror = function() {
if (_clarifyEventSource) { try { _clarifyEventSource.close(); } catch(_){} _clarifyEventSource = null; }
if (_clarifyHealthTimer) { clearInterval(_clarifyHealthTimer); _clarifyHealthTimer = null; }
_startClarifyFallbackPoll(sid);
};
// Stale-detector: track last event timestamp; only reconnect if no event
// (initial or clarify) has arrived in 60s. The server sends a keepalive
// comment line every 30s but EventSource silently consumes those; we only
// bump lastEventAt on actual application events. With no real events for
// 60s on a long-lived clarify connection the server is effectively silent
// and a reconnect is the safe move.
//
// Without the lastEventAt gate the original PR force-reconnected every 60s
// regardless of activity, which churned one TCP/SSE setup per minute per
// active session. (Opus pre-release review of v0.50.249.)
let _lastClarifyEventAt = Date.now();
const _markClarifyEvent = () => { _lastClarifyEventAt = Date.now(); };
_clarifyEventSource.addEventListener('initial', _markClarifyEvent);
_clarifyEventSource.addEventListener('clarify', _markClarifyEvent);
_clarifyHealthTimer = setInterval(function() {
if (Date.now() - _lastClarifyEventAt < 60000) return;
if (_clarifyEventSource) {
try { _clarifyEventSource.close(); } catch(_){}
_clarifyEventSource = null;
}
clearInterval(_clarifyHealthTimer); _clarifyHealthTimer = null;
startClarifyPolling(sid);
}, 60000);
}
function _startClarifyFallbackPoll(sid) {
_clarifyPollingSessionId = sid || null;
_clarifyFallbackTimer = setInterval(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) || "");
if (!_clarifyMissingEndpointWarned && /(^|\b)(404|not found)(\b|$)/i.test(msg)) {
_clarifyMissingEndpointWarned = true;
setComposerStatus("Clarify unavailable on current server build. Restart server.");
if (typeof showToast === "function") {
showToast("Clarify endpoint unavailable. Please restart server.", 5000);
}
stopClarifyPolling();
}
} finally {
_clarifyFallbackPollInFlight = false;
}
}, 3000);
}
function stopClarifyPollingForSession(sid) {
if(sid && _clarifyPollingSessionId && _clarifyPollingSessionId!==sid) return;
stopClarifyPolling();
}
function stopClarifyPolling() {
if (_clarifyEventSource) { try { _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 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 sendBrowserNotification(title,body){
if(!window._notificationsEnabled||!document.hidden) return;
if(!('Notification' in window)) return;
const botName=assistantDisplayName();
if(Notification.permission==='granted'){
new Notification(title||botName,{body:body});
}else if(Notification.permission!=='denied'){
Notification.requestPermission().then(p=>{
if(p==='granted') new Notification(title||botName,{body:body});
});
}
}
// ── /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) ──