`;
// Expand/collapse via delegated click handler (init at module bottom).
threadWrap.appendChild(node);
currentToolBubble = node;
if (_isPrivateBrowserTool(json.tool)) {
_showPrivateBrowserPreview(json.command || '', node.querySelector('.agent-thread-content'));
}
// Animate the wave
const waveEl = node.querySelector('.agent-thread-wave');
if (waveEl) {
const waveFrames = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
let waveIdx = 0;
node._waveInterval = setInterval(() => {
waveIdx = (waveIdx + 1) % waveFrames.length;
waveEl.textContent = waveFrames[waveIdx];
}, 100);
}
// Smooth per-second "cooking" timer — ticks every second (not
// just on the 2s backend heartbeat) so a long-running tool
// always shows visible motion and never reads as frozen.
node._startTime = Date.now();
node._elapsedTicker = setInterval(() => {
const hdr2 = node.querySelector('.agent-thread-header');
if (!hdr2) return;
let el2 = hdr2.querySelector('.agent-thread-elapsed');
if (!el2) {
el2 = document.createElement('span');
el2.className = 'agent-thread-elapsed';
// Sits on the LEFT, right after the icon.
const icon = hdr2.querySelector('.agent-thread-icon');
if (icon && icon.nextSibling) hdr2.insertBefore(el2, icon.nextSibling);
else hdr2.appendChild(el2);
}
const s = (Date.now() - node._startTime) / 1000;
// Hundredths so it visibly counts sub-second (1.00, 1.05, …).
el2.textContent = s < 60 ? `${s.toFixed(2)}s` : `${Math.floor(s / 60)}m ${(s % 60).toFixed(2).padStart(5, '0')}s`;
}, 50);
uiModule.scrollHistory();
} else if (json.type === 'editor_progress') {
if (_isBg) continue;
_editorProgress = json;
if (spinner?.element) _startTtftDisplay();
} else if (json.type === 'tool_progress') {
// Long-running subprocess (bash, python) is still in
// flight — refresh the running tool card with the
// elapsed-time + tail of its stdout/stderr so the
// user doesn't stare at a blind "Running…" spinner.
if (_isBg) continue;
if (!currentToolBubble) continue;
const isImageProgress = /image/i.test(String(json.tool || '')) || /image/i.test(String(json.message || ''));
if (json.total || json.percent != null || isImageProgress) {
const content = currentToolBubble.querySelector('.agent-thread-content');
if (content) {
let progressEl = currentToolBubble.querySelector('.agent-image-progress');
if (!progressEl) {
progressEl = document.createElement('div');
progressEl.className = 'agent-image-progress';
progressEl.innerHTML = '
';
content.appendChild(progressEl);
}
const step = Number(json.step || 0);
const total = Number(json.total || 0);
const hasExactProgress = total > 0 || json.percent != null;
progressEl.classList.toggle('is-indeterminate', !hasExactProgress);
const pct = Number(json.percent != null ? json.percent : (total ? (step / total) * 100 : 0));
const bounded = Math.max(0, Math.min(100, Number.isFinite(pct) ? pct : 0));
const label = progressEl.querySelector('.agent-image-progress-label');
const value = progressEl.querySelector('.agent-image-progress-value');
if (label) label.textContent = json.message || 'Editing image…';
if (value) value.textContent = hasExactProgress ? (total ? `${step}/${total}` : `${Math.round(bounded)}%`) : (json.elapsed ? `${json.elapsed}s` : '');
}
}
// The per-second ticker (started in tool_start) owns the
// elapsed display; here we just surface the live output tail.
const tailStr = (json.tail || '').trim();
if (tailStr) {
let tailEl = currentToolBubble.querySelector('.agent-thread-tail');
if (!tailEl) {
tailEl = document.createElement('pre');
tailEl.className = 'agent-thread-tail';
tailEl.style.cssText = 'margin:4px 0 0;padding:6px 8px;font-size:11px;background:rgba(0,0,0,0.18);border-radius:4px;max-height:140px;overflow:auto;white-space:pre-wrap;opacity:0.85;';
const content = currentToolBubble.querySelector('.agent-thread-content');
if (content) content.appendChild(tailEl);
}
tailEl.textContent = tailStr;
tailEl.scrollTop = tailEl.scrollHeight;
}
uiModule.scrollHistory();
} else if (json.type === 'tool_output') {
// Let feature panels reconcile state from the actual tool
// result rather than trying to infer completion from the
// assistant's prose. Email unsubscribe uses this for the
// exact UIDs deleted by an agent follow-up.
try {
window.dispatchEvent(new CustomEvent('odysseus:agent-tool-output', { detail: json }));
} catch (_) {}
if (_isBg) continue;
// --- Update the current thread node ---
if (currentToolBubble) {
// Stop wave animation + the per-second cooking ticker
if (currentToolBubble._waveInterval) {
clearInterval(currentToolBubble._waveInterval);
currentToolBubble._waveInterval = null;
}
if (currentToolBubble._elapsedTicker) {
clearInterval(currentToolBubble._elapsedTicker);
currentToolBubble._elapsedTicker = null;
}
const ok = (json.exit_code === 0 || json.exit_code == null);
const cmd = json.command || '';
let outHtml = '';
if (!_suppressRawToolOutput(json.tool, ok) && json.output && json.output.trim()) {
outHtml = `Output
${esc(json.output)}
`;
}
// File-write diff (write_file): show a before/after unified diff.
let diffHtml = '';
if (json.diff && json.diff.text) {
const d = json.diff;
// Collapsed summary: filename + +adds (green) / −dels (red).
const stat = [
d.new_file ? 'new' : '',
d.added ? `+${d.added}` : '',
d.removed ? `−${d.removed}` : '',
].filter(Boolean).join(' ');
const rows = d.text.split('\n').map(line => {
let cls = 'diff-ctx', text = line;
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
else if (line.startsWith('@@')) cls = 'diff-hunk';
// Drop the leading diff marker (+/-/space) — the row colour
// already encodes add/del, and keeping it doubles up with
// markdown "- " bullets (reads as "+-"/"--").
else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
else if (line.startsWith(' ')) { text = line.slice(1); }
return `${esc(text) || ' '}`;
}).join(''); // spans are display:block — a literal \n here would double-space the diff
diffHtml = `${esc(d.file || 'diff')}${stat}
${rows}
`;
}
// For file edits the "command" is the raw JSON args — redundant
// next to the diff, so hide it when we have a diff to show.
const toolInfo2 = _toolDisplayInfo(json.tool, cmd, json);
const browserTool = _isPrivateBrowserTool(json.tool);
const toolHeaderAction2 = toolInfo2.headerActionHtml || '';
const toolIcon2 = renderToolIcon(json.tool, cmd, json);
const cmdHtml2 = browserTool ? '' : (toolInfo2.commandHtml || ((cmd && !(json.diff && json.diff.text)) ? `
${esc(cmd)}
` : ''));
// Preserve the user's .open choice across the innerHTML
// rewrite \u2014 otherwise expanding a running tool collapses
// it as soon as the result lands, forcing the user to
// click again. Click handling is delegated (see init at
// bottom of file) so no per-node listener needed.
if (browserTool) {
outHtml = _privateBrowserPreviewHtml(cmd, json.screenshot) + outHtml;
}
const _wasOpen = currentToolBubble.classList.contains('open') || browserTool;
currentToolBubble.className = 'agent-thread-node' + (ok ? '' : ' error') + (_wasOpen ? ' open' : '') + (browserTool ? ' browser-preview-node' : '');
currentToolBubble.innerHTML = `
`;
// Reset so thinking spinner between tools says "Thinking" not the old tool's label
_lastToolName = '';
uiModule.scrollHistory();
}
// --- Render generated images inline ---
if (json.image_url) {
_rememberGeneratedImage(json);
_appendGeneratedImageBubble(json, streamSessionId);
}
// --- Render browser screenshots in tool output ---
if (json.screenshot && currentToolBubble) {
const contentEl = currentToolBubble.querySelector('.agent-thread-content');
if (contentEl) {
_updatePrivateBrowserPreview(json, contentEl);
const screenshotSrc = chatRenderer.safeToolScreenshotSrc(json.screenshot);
if (screenshotSrc && !_isPrivateBrowserTool(json.tool)) {
const details = document.createElement('details');
details.className = 'agent-tool-output';
const summary = document.createElement('summary');
summary.textContent = 'Screenshot';
const img = document.createElement('img');
img.src = screenshotSrc;
img.style.cssText = 'max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)';
details.appendChild(summary);
details.appendChild(img);
contentEl.appendChild(details);
}
}
}
// --- Reload sessions after manage_session tool (delete, rename, etc.) ---
// Debounce so bulk deletes don't fire loadSessions per call
if (json.tool === 'manage_session' && sessionModule) {
if (window._manageSessionTimer) clearTimeout(window._manageSessionTimer);
window._manageSessionTimer = setTimeout(() => sessionModule.loadSessions(), 1000);
}
// --- Live-refresh the calendar after manage_calendar (add/edit/delete) ---
// so a new event shows without the user hard-refreshing. Debounced
// so a batch of event creates only triggers one refetch.
if (json.tool === 'manage_calendar') {
if (window._manageCalTimer) clearTimeout(window._manageCalTimer);
window._manageCalTimer = setTimeout(
() => window.dispatchEvent(new CustomEvent('calendar-refresh')), 600);
}
// --- Live-refresh Memories after manage_memory changes ---
if (json.tool === 'manage_memory') {
if (window._manageMemoryTimer) clearTimeout(window._manageMemoryTimer);
window._manageMemoryTimer = setTimeout(
() => window.dispatchEvent(new CustomEvent('memory-refresh')), 600);
}
// --- Apply UI control actions embedded in tool_output ---
if (json.ui_event) {
chatStream.handleUIControl(json);
}
// Native document tool calls can arrive as a completed
// tool_output without the text-fence streaming path. Open the
// document editor from the real doc metadata carried on the
// tool result so "create a document" never leaves only a chat
// link behind if the later doc_update event is missed.
if (
documentModule
&& json.doc_id
&& ['create_document', 'update_document', 'edit_document',
'draft_email', 'mcp__email__draft_email',
'draft_email_reply', 'mcp__email__draft_email_reply',
'ai_draft_email_reply', 'mcp__email__ai_draft_email_reply'].includes(json.tool)
) {
if (['draft_email', 'mcp__email__draft_email',
'draft_email_reply', 'mcp__email__draft_email_reply',
'ai_draft_email_reply', 'mcp__email__ai_draft_email_reply'].includes(json.tool)
&& documentModule.loadDocument) {
documentModule.loadDocument(json.doc_id);
} else {
documentModule.handleDocUpdate({
type: 'doc_update',
doc_id: json.doc_id,
title: json.document_title || '',
language: json.document_language || '',
version: json.document_version || 1,
content: json.document_content || '',
});
}
}
// Schedule a thinking spinner between tool rounds (short delay so
// agent_step in the same SSE chunk can cancel it before it shows)
_scheduleToolWaitSpinner();
uiModule.scrollHistory();
} else if (json.type === 'doc_stream_open') {
if (_isBg) {
// Store for replay when user returns to this session
var bgDocOpen = _backgroundStreams.get(streamSessionId);
if (bgDocOpen) {
bgDocOpen._docTitle = json.title || '';
bgDocOpen._docLang = json.language || '';
bgDocOpen._docContent = '';
}
continue;
}
if (documentModule) {
documentModule.streamDocOpen(json.title || '', json.language || '');
}
} else if (json.type === 'doc_stream_delta') {
if (_isBg) {
var bgDocDelta = _backgroundStreams.get(streamSessionId);
if (bgDocDelta) bgDocDelta._docContent = json.content || '';
continue;
}
if (documentModule) {
documentModule.streamDocDelta(json.content || '');
}
} else if (json.type === 'doc_update') {
// doc_update means the server already saved the doc to DB.
if (_isBg) continue;
_editorProgress = null;
if (documentModule) {
documentModule.handleDocUpdate(json);
}
offerFinishEditorTurn();
} else if (json.type === 'doc_suggestions') {
if (_isBg) continue;
_editorProgress = null;
if (documentModule && documentModule.handleDocSuggestions) {
documentModule.handleDocSuggestions(json);
}
offerFinishEditorTurn();
} else if (json.type === 'email_open') {
if (_isBg) continue;
import('./emailLibrary.js?v=20260915trashmove2').then(mod =>
mod.openEmailFromTool(json, () => sessionModule.getCurrentSessionId() === streamSessionId)
).catch(err => uiModule.showToast(`Could not open email: ${err.message}`));
} else if (json.type === 'ui_control') {
if (_isBg) continue;
chatStream.handleUIControl(json.data || {});
} else if (json.type === 'ask_user') {
if (_isBg) continue;
// The agent posed a multiple-choice question; the turn has ended.
// Use the shared history renderer so the live and restored
// versions have identical behavior.
_cancelThinkingTimer();
_removeThinkingSpinner();
chatRenderer.renderAskUserCard(json.data || {});
} else if (json.type === 'plan_update') {
if (_isBg) continue;
// Agent wrote back to the plan (ticked a step / revised). Update
// the stored plan + live-refresh the docked plan window.
const _pu = (json.data && json.data.plan) ? json.data.plan : '';
if (_pu) _setStoredPlan(_pu);
} else if (json.type === 'agent_step') {
if (!_turnRendering.accepts(json)) continue;
if (!startsContinuationRound(json)) {
// Keep the current label stable; the TTFT ticker owns it
// until visible output arrives.
continue;
}
_closeOpenThinkingMarkup(_isBg);
if (_isBg) continue;
_cancelThinkingTimer();
_removeThinkingSpinner();
if (isThinking) {
_endLiveThinkingSection({ rich: false });
} else {
_cancelLiveThinkingWork();
}
_finalizeRoundRender();
// Mark thread as connected to bubble below
const _activeThread = document.querySelector('.agent-thread.streaming');
if (_activeThread) {
_activeThread.classList.add('has-bottom');
}
// --- New round: create fresh AI bubble with spinner ---
currentToolBubble = null;
roundFinalized = false;
terminalFinalResponseRendered = false;
roundFinalization = null;
isThinking = false;
roundReplyText = null;
_thinkingMode = null;
_thinkingRecheckAt = 0;
_thinkingAnalysisGate.reset();
_roundDisplayProjector.reset();
_replyDisplayProjector.reset();
_docFenceOpened = false;
const box = document.getElementById('chat-history');
const newWrap = document.createElement('div');
newWrap.className = 'msg msg-ai msg-continuation streaming';
// Add model name label
const newRole = document.createElement('div');
newRole.className = 'role';
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
inheritModelRouteState(holder, roundHolder, newWrap, metaS?.model || modelName);
const _roundRequested = newWrap._requestedModel;
const _roundActual = newWrap._actualModel;
newRole.textContent = _modelRouteLabel(
_roundRequested,
_roundActual,
newWrap._requestedEndpointLabel,
newWrap._actualEndpointLabel,
newWrap._requestedEndpointId,
newWrap._actualEndpointId,
) || '';
_applyModelColor(newRole, _roundActual);
newWrap.appendChild(newRole);
const newBody = document.createElement('div');
newBody.className = 'body';
newWrap.appendChild(newBody);
box.appendChild(newWrap);
roundHolder = newWrap;
roundText = '';
// Destroy any previous spinner before creating new one
if (spinner && spinner.element) spinner.destroy();
// Show spinner while waiting for text (skip for research — has its own progress)
if (!_researchingStreamIds.has(streamSessionId)) {
spinner = spinnerModule.create('Generating response', 'right', 'wave');
newBody.appendChild(spinner.createElement());
spinner.start();
}
if (streamingTTS) window.aiTTSManager._streamSentencesSent = 0;
uiModule.scrollHistory();
} else if (json.type === 'budget_exceeded') {
if (_isBg) continue;
_cancelThinkingTimer();
_removeThinkingSpinner();
const budgetDiv = document.createElement('div');
budgetDiv.style.cssText = 'font-size:11px;opacity:0.6;font-style:italic;padding:4px 8px;margin:4px 0;';
budgetDiv.textContent = `Tool budget reached (${json.used}/${json.limit} calls). Agent stopped.`;
const chatBox = document.getElementById('chat-history');
chatBox.appendChild(budgetDiv);
} else if (json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted') {
if (_isBg) continue;
_cancelThinkingTimer();
_removeThinkingSpinner();
const guardDiv = document.createElement('div');
guardDiv.className = 'stopped-indicator';
const guardLabel = document.createElement('span');
guardLabel.textContent = `[Agent guard: ${json.message || json.reason || 'internal stop'}]`;
guardDiv.appendChild(guardLabel);
const targetBody = roundHolder && roundHolder.querySelector('.body');
if (targetBody) targetBody.appendChild(guardDiv);
else {
const chatBox = document.getElementById('chat-history');
if (chatBox) chatBox.appendChild(guardDiv);
}
} else if (json.type === 'teacher_takeover') {
if (!_turnRendering.accepts(json)) continue;
if (_isBg) continue;
_cancelThinkingTimer();
_removeThinkingSpinner();
// Finalize any in-flight bubble so the takeover banner
// separates student attempt from teacher attempt.
if (spinner && spinner.element) { try { spinner.destroy(); } catch(_){} spinner = null; }
const chatBox = document.getElementById('chat-history');
const banner = document.createElement('div');
banner.className = 'teacher-takeover-banner';
banner.style.cssText = 'margin:10px 0;padding:8px 12px;border-left:3px solid #c08a3e;background:rgba(192,138,62,0.08);font-size:12px;color:var(--fg);border-radius:4px;';
const teacherName = json.teacher_model || 'teacher';
const why = json.student_failure ? ` — ${esc(json.student_failure)}` : '';
banner.innerHTML = `Teacher takeover: escalating to ${esc(teacherName)}${why}`;
chatBox.appendChild(banner);
// Reset round bubble state so the teacher's first text starts a new bubble
roundHolder = null;
roundText = '';
roundFinalized = false;
terminalFinalResponseRendered = false;
roundFinalization = null;
currentToolBubble = null;
uiModule.scrollHistory();
} else if (json.type === 'skill_saved') {
if (_isBg) continue;
const chatBox = document.getElementById('chat-history');
const note = document.createElement('div');
note.className = 'skill-saved-note';
note.style.cssText = 'margin:6px 0;padding:6px 10px;border-left:3px solid #4a8a4a;background:rgba(74,138,74,0.07);font-size:12px;color:var(--fg);border-radius:4px;';
note.innerHTML = `Skill learned:${esc(json.name || '')}${json.category ? ` [${esc(json.category)}]` : ''}`;
chatBox.appendChild(note);
uiModule.scrollHistory();
} else if (json.type === 'escalation_failed' || json.type === 'skill_save_failed') {
if (_isBg) continue;
const chatBox = document.getElementById('chat-history');
const note = document.createElement('div');
note.className = 'escalation-failed-note';
note.style.cssText = 'margin:6px 0;padding:6px 10px;border-left:3px solid #8a4a4a;background:rgba(138,74,74,0.07);font-size:12px;color:var(--fg);border-radius:4px;';
const label = json.type === 'escalation_failed' ? 'Teacher could not solve it' : 'Skill not saved';
note.innerHTML = `${label}:${esc(json.reason || '')}`;
chatBox.appendChild(note);
uiModule.scrollHistory();
} else if (json.error) {
// --- Backend error (timeout, connection issue, etc.) ---
console.error('Stream error from backend:', json.error);
if (_isBg) continue;
if (spinner && spinner.element) spinner.destroy();
const errDiv = document.createElement('div');
errDiv.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;';
errDiv.textContent = `[Error: ${json.error}]`;
roundHolder.querySelector('.body').appendChild(errDiv);
uiModule.scrollHistory();
}
} catch (e) {
console.error('Error parsing SSE data:', e);
}
}
}
}
if (_streamTerminalError) {
throw _streamTerminalError;
}
if (!_streamSawDone) {
if (!_canonicalTerminalSaved) {
throw new Error('Stream closed before completion');
}
// The backend persisted a canonical terminal record (partial output +
// failure metadata) before the connection died. Route through the
// terminal-error path so that record is reloaded; falling through to
// the success renderer would present the partial output as a clean
// completion.
throw createTerminalStreamError({
text: 'Stream closed after canonical terminal event',
});
}
// The final foreground render below is authoritative. Cancel any delayed
// live-view work instead of parsing and rendering the full round once
// here and then immediately replacing it.
_cancelLiveThinkingWork();
if (spinner && spinner.element) { try { spinner.destroy(); } catch (_) {} spinner = null; }
_cancelThinkingTimer();
_removeThinkingSpinner();
// Stop any thread pulse animations
document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming'));
// --- Final render (skip if stream was ever backgrounded or currently in background) ---
const _isBgFinal = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
if (!_isBgFinal) {
if (!terminalFinalResponseRendered && !_turnRendering.isVisible(roundHolder.querySelector('.body'), _terminalAnswerHtml(roundText, roundHolder.querySelector('.body')))) _renderStream();
if (spinner && spinner.element) { try { spinner.destroy(); } catch (_) {} spinner = null; }
_cancelThinkingTimer();
_removeThinkingSpinner();
// Stop this visible thread's pulse animations.
document.querySelectorAll('#chat-history .agent-thread.streaming').forEach(t => t.classList.remove('streaming'));
}
// Remove streaming class from all round bubbles
holder.classList.remove('streaming');
if (roundHolder && roundHolder !== holder) roundHolder.classList.remove('streaming');
if (!_isBgFinal) {
finalMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
const _finalModelHolder = applyModelMetricsState(
metrics,
holder,
roundHolder,
finalMeta?.model || modelName,
) || holder;
const _finalActualModel = _finalModelHolder._actualModel || finalMeta?.model;
const _finalRequestedModel = _finalModelHolder._requestedModel || finalMeta?.model || _finalActualModel;
// Prepend character name if set
var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
const roleEl = _finalModelHolder.querySelector('.role');
if (roleEl) {
_setRoleModelLabel(roleEl, _finalRequestedModel, _finalActualModel, {
suffix: _finalModelHolder._roleSuffix,
characterName: _charNameFinal || _finalModelHolder._characterName,
requestedEndpointId: _finalModelHolder._requestedEndpointId,
requestedEndpointLabel: _finalModelHolder._requestedEndpointLabel,
actualEndpointId: _finalModelHolder._actualEndpointId,
actualEndpointLabel: _finalModelHolder._actualEndpointLabel,
});
}
holder.dataset.raw = accumulated;
// Anti-stall: a turn that ran tools but ended with essentially no
// final prose usually means the model stopped mid-task (the case
// where you had to type "did you finish?"). Offer a one-click
// Continue that resumes exactly where it left off — reuses the same
// resume mechanism as the user-stop "[Message interrupted]" button.
try {
const _usedTools = holder.querySelector('.agent-thread-node');
const _proseLen = (accumulated || '').replace(/<[^>]*>/g, '').trim().length;
if (_usedTools && _proseLen < 24 && !holder.querySelector('.agent-continue-btn')) {
const _stall = document.createElement('div');
_stall.className = 'stopped-indicator';
const _lbl = document.createElement('span');
_lbl.style.cssText = 'font-style:italic;opacity:0.7;';
_lbl.textContent = 'Paused mid-task';
_stall.appendChild(_lbl);
const _cont = document.createElement('button');
_cont.className = 'continue-btn resume-btn agent-continue-btn';
_cont.title = 'Continue — pick up where it left off';
_cont.innerHTML = 'Resume';
_cont.addEventListener('click', () => {
_stall.remove();
const mi = uiModule.el('message');
if (mi) {
mi.value = 'Continue — you stopped before finishing. Pick up exactly where you left off and complete the task.';
const sb = document.querySelector('.send-btn');
if (sb) sb.click();
}
});
_stall.appendChild(_cont);
(holder.querySelector('.body') || holder).appendChild(_stall);
}
} catch (_) {}
const _terminalScrollSnapshot = uiModule.captureHistoryScroll?.();
// Clear streaming minHeight lock
const _streamContent = roundHolder.querySelector('.stream-content');
if (_streamContent) _streamContent.style.minHeight = '';
if (_docFenceOpened) {
_finishDocumentWritingStatus(roundHolder, true);
roundHolder.style.display = '';
}
// Finalize the last round's bubble — flatten stream-content wrapper for clean DOM
const finalDisplay = terminalFinalResponseRendered ? '' : _streamDisplayText(roundText, { final: _docFenceOpened });
if (terminalFinalResponseRendered) {
// A canonical answer must not fall through to the empty-round
// branch below, which hides continuation bubbles.
_renderTerminalAnswer(roundText);
} else if (finalDisplay.trim() && _turnRendering.isVisible(roundHolder.querySelector('.body'), _terminalAnswerHtml(finalDisplay, roundHolder.querySelector('.body')))) {
// Preserve the actual streamed nodes, including selection.
} else if (finalDisplay.trim()) {
var _body4 = roundHolder.querySelector('.body');
// Preserve sources expanded state before final render
var _wasExpanded = _sourcesExpanded || !!(_body4 && _body4.querySelector('.sources-content.expanded'));
if (_suppressThinkingForPersona()) {
const _personaFinal = _visiblePersonaReplyText(finalDisplay);
_body4.innerHTML = (_sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded) : '')
+ (_personaFinal.trim() ? markdownModule.mdToHtml(markdownModule.squashOutsideCode(_personaFinal)) : '')
+ (_findingsData ? chatRenderer.buildFindingsBox(_findingsData) : '');
} else {
// If thinking was collapsed in-place during streaming, preserve it
var _liveReplyEl = _body4 && _body4.querySelector('.live-reply-content');
var _extracted = _liveReplyEl ? markdownModule.extractThinkingBlocks(finalDisplay) : null;
var _finalReply = '';
if (_liveReplyEl) {
// Try standard extraction first (for native tags)
if (_extracted?.thinkingBlocks?.length) {
_finalReply = (_extracted.content || '').trim();
} else {
// Non-tag thinking: extract reply from raw text
// Handle garbled thinking tag: "Thinking: reasoning\nreply"
const _garbledMatch = finalDisplay.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
if (_garbledMatch && _garbledMatch[1].trim()) {
_finalReply = _garbledMatch[1].trim();
} else {
// Pure non-tag: find reply boundary by prefix patterns
const _rs2 = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
const _fr = (finalDisplay || '').trimStart();
if (markdownModule.startsWithReasoningPrefix(_fr)) {
const _fLines = _fr.split('\n');
for (let _fi = 1; _fi < _fLines.length; _fi++) {
const _fl = _fLines[_fi].trim();
if (!_fl) continue;
if (_rs2.some(rp => _fl.startsWith(rp))) { _finalReply = _fLines.slice(_fi).join('\n'); break; }
}
// Within-line check
if (!_finalReply) {
for (const rp of _rs2) {
const rx = new RegExp('[.!?]\\s*(' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')');
const m = rx.exec(_fr);
if (m && m.index > 20) { _finalReply = _fr.slice(m.index + 1).trim(); break; }
}
}
}
}
}
}
if (_liveReplyEl && _finalReply) {
// Render reply into the live-reply container (thinking bar already showing)
var _replyHtml = markdownModule.mdToHtml(markdownModule.squashOutsideCode(_finalReply));
_liveReplyEl.innerHTML = _replyHtml;
_liveReplyEl.classList.remove('live-reply-content');
if (_sourcesData) {
var _srcEl = document.createElement('div');
_srcEl.innerHTML = _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded);
_body4.insertBefore(_srcEl.firstChild || _srcEl, _body4.firstChild);
}
if (_findingsData) _body4.insertAdjacentHTML('beforeend', chatRenderer.buildFindingsBox(_findingsData));
} else {
// Full re-render (reply empty or no live-reply container)
_body4.innerHTML = (_sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded) : '')
+ markdownModule.processWithThinking(markdownModule.squashOutsideCode(finalDisplay))
+ (_findingsData ? chatRenderer.buildFindingsBox(_findingsData) : '');
}
}
} else if (_sourcesHtml) {
var _body4b = roundHolder.querySelector('.body');
var _wasExpanded2 = _sourcesExpanded || !!(_body4b && _body4b.querySelector('.sources-content.expanded'));
_body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml;
} else if (roundHolder !== holder) {
// Check if there's thinking content worth showing
const _thinkingOnly = markdownModule.extractThinkingBlocks(_streamDisplayText(roundText));
if (_thinkingOnly.thinkingBlocks?.length && !_thinkingOnly.content) {
// Show thinking in a collapsed section even if no visible reply text
const _body4c = roundHolder.querySelector('.body');
if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(_streamDisplayText(roundText));
} else {
roundHolder.style.display = 'none';
// Thread above expected a bubble below — remove has-bottom since bubble is hidden
const _lastThread = roundHolder.previousElementSibling;
if (_lastThread && _lastThread.classList.contains('agent-thread')) {
_lastThread.classList.remove('has-bottom');
}
}
}
if (window.hljs) {
roundHolder.querySelectorAll('pre code').forEach((block) => {
window.hljs.highlightElement(block);
});
}
if (markdownModule.renderMermaid) markdownModule.renderMermaid(roundHolder);
// Render RAG sources if present
if (holder._ragSources && holder._ragSources.length) {
const details = document.createElement('details');
details.className = 'rag-sources';
const summary = document.createElement('summary');
summary.textContent = `Sources (${holder._ragSources.length} documents)`;
details.appendChild(summary);
holder._ragSources.forEach(src => {
const item = document.createElement('div');
item.className = 'rag-source-item';
const _esc = uiModule.esc;
item.innerHTML = `${_esc(src.filename)}${(src.similarity * 100).toFixed(1)}%
${_esc(src.snippet)}
`;
details.appendChild(item);
});
holder.querySelector('.body').appendChild(details);
}
// Hide first bubble if it has no visible text content (e.g. agent went straight to tools)
if (holder !== roundHolder && holder.style.display !== 'none') {
const _hBody = holder.querySelector('.body');
const _hText = _hBody ? _hBody.textContent.trim() : '';
if (!_hText) holder.style.display = 'none';
}
// Attach footer to the last visible bubble (roundHolder for multi-round agent, holder for single)
const footerTarget = (roundHolder && roundHolder !== holder && roundHolder.style.display !== 'none') ? roundHolder : holder;
if (!footerTarget.querySelector('.msg-footer')) {
footerTarget.appendChild(createMsgFooter(footerTarget, { animate: true }));
}
if (_generatedImagesForTurn.length && !_isBgFinal) {
_generatedImagesForTurn.forEach(imgData => _appendGeneratedImageBubble(imgData, streamSessionId));
}
// Add "View Report" link for completed research
if (_researchingStreamIds.has(streamSessionId)) {
_appendViewReportLink(footerTarget, streamSessionId);
}
// Also store raw on the footer target so copy/TTS work
if (footerTarget !== holder) footerTarget.dataset.raw = accumulated;
try {
const _endToggles = Storage.loadToggleState();
if (_endToggles.plan_mode && accumulated) {
_setStoredPlan(accumulated);
_attachPlanActions(footerTarget, accumulated);
}
} catch (_) {}
if (addAITTSButton && accumulated && window.aiTTSManager?._provider !== 'disabled' && window.aiTTSManager?.available) {
addAITTSButton(footerTarget, accumulated);
}
// TTS auto-play: streaming mode flushes remaining text, non-streaming enqueues full message
if (accumulated && window.aiTTSManager && window.aiTTSManager.autoPlay) {
const ttsBtn = holder.querySelector('.ai-tts-button');
if (ttsBtn) {
var ICON_PLAY_TTS = '';
var ICON_STOP_TTS = '';
const resetFn = () => {
ttsBtn.innerHTML = ICON_PLAY_TTS;
ttsBtn.classList.remove('playing', 'loading');
ttsBtn.style.color = '#6b7280';
ttsBtn.title = 'Read aloud';
};
if (streamingTTS) {
// Flush remaining partial sentence and attach the real button
window.aiTTSManager.streamingEnd(accumulated);
window.aiTTSManager.streamingAttachButton(ttsBtn, resetFn);
// If still playing sentences from the stream, show stop icon
if (window.aiTTSManager.isPlaying || window.aiTTSManager._processing) {
ttsBtn.innerHTML = ICON_STOP_TTS;
ttsBtn.classList.add('playing');
ttsBtn.style.color = '#ccc';
ttsBtn.title = 'Stop';
}
} else {
// Non-streaming fallback (autoPlay toggled mid-stream, etc.)
window.aiTTSManager.enqueue(accumulated, ttsBtn, resetFn);
}
}
}
if (metrics) {
displayMetrics(_metricsTargetForTurn() || footerTarget, metrics);
}
// Attach variant navigation if this was a regeneration
_attachVariantNav(footerTarget);
// Merge with previous stopped message if this was a continue
if (_pendingContinue) {
const prevEl = _pendingContinue;
_pendingContinue = null;
const prevBody = prevEl.querySelector('.body');
const newBody = footerTarget.querySelector('.body');
if (prevBody && newBody && prevEl.parentNode) {
// Merge: combine raw text with *(continued)* marker
const oldRaw = prevEl.dataset.raw || '';
const newRaw = footerTarget.dataset.raw || '';
const mergedRaw = oldRaw + '\n\n*(continued)*\n\n' + newRaw;
prevEl.dataset.raw = mergedRaw;
// Re-render merged content
prevBody.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(mergedRaw)
);
// Remove the new bubble and re-add footer to the merged one
footerTarget.remove();
const oldFooter = prevEl.querySelector('.msg-footer');
if (oldFooter) oldFooter.remove();
prevEl.appendChild(createMsgFooter(prevEl));
if (window.hljs) {
prevEl.querySelectorAll('pre code').forEach(block => window.hljs.highlightElement(block));
}
// Persist merge to server
const sid = sessionModule.getCurrentSessionId();
if (sid) {
fetch(`${API_BASE}/api/session/${sid}/merge-last-assistant`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ separator: '\n\n*(continued)*\n\n' })
}).catch(e => console.warn('merge-last-assistant failed:', e));
}
}
}
// Reconcile only the final answer; the tool timeline retains its DOM.
uiModule.restoreHistoryScroll?.(_terminalScrollSnapshot);
// A saved answer can correct the live answer without rebuilding the turn.
if (!_pendingContinue) {
const _needsCanonicalTurnRebuild = !!(
lastToolThread
|| terminalFinalResponseRendered
|| _generatedImagesForTurn.length
);
if (_needsCanonicalTurnRebuild) {
await _replaceLiveTurnWithSavedAssistantMessage();
}
if (_streamTurnMarker && _streamTurnMarker.parentNode) {
_turnRendering.settle();
_streamTurnMarker.remove();
}
}
} // end if (!_isBgFinal)
} catch (err) {
// If a Stop or timeout was waiting for an identity header and the POST
// failed before producing one, keep this on the cancellation path. There
// is no safe headerless server cancel to send, but it must not be turned
// into an automatic recovery attempt either. Only this send's own
// queued Stop counts; a replacement's queued Stop is not ours to spend.
const _pendingCatchKey = streamSessionId + ':' + streamGeneration;
if (
_pendingRunStops.has(_pendingCatchKey)
&& abortCtrl
&& !abortCtrl.signal.aborted
) {
_pendingRunStops.delete(_pendingCatchKey);
abortCtrl._reason = 'user-stop';
abortCtrl.abort();
}
// Check if this stream was running in background — needed before any
// stop-state write, so an errored background stream can't clobber the
// foreground session's text.
const _isBgCatch = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
let _catchTerminalView = null;
_closeOpenThinkingMarkup(_isBgCatch);
if (_isBgCatch) {
_cancelLiveThinkingWork();
// A canonical terminal event may have been persisted immediately
// before the stream moved into the background. Preserve that terminal
// state instead of allowing the catch path to turn it back into a
// running/error stream.
const bgTerminal = _backgroundStreams.get(streamSessionId);
if (bgTerminal && _terminalSavedStreams.has(streamSessionId)) {
bgTerminal.status = 'completed';
if (sessionModule && sessionModule.clearStreaming) {
sessionModule.clearStreaming(streamSessionId);
}
}
} else if (accumulated) {
_catchTerminalView = _finalizeInterruptedView();
} else {
// Empty terminal views are owned by _renderCancelledBubble; do not run
// the rich round renderer first because it hides an empty holder.
_endThinkingOnTerminalPath({ rich: false });
}
const _catchViewHolder = _catchTerminalView?.holder || holder;
if (_isBgCatch) {
// Detaching intentionally closes only this browser subscriber. The
// server-owned run is still live and will be rejoined on session entry.
var bgErr = _backgroundStreams.get(streamSessionId);
if (abortCtrl && abortCtrl._reason === 'detach') {
if (bgErr && bgErr.status !== 'completed') bgErr.status = 'running';
} else if (bgErr && (
bgErr.status === 'completed' || _terminalSavedStreams.has(streamSessionId)
)) {
bgErr.status = 'completed';
// [DONE] was already processed — this error is benign (e.g. reader.read() after close)
// Don't override the completed status; just ensure the completed dot stays
if (sessionModule && sessionModule.clearStreaming) {
sessionModule.clearStreaming(streamSessionId);
}
} else if (bgErr) {
console.error('Background stream error:', err);
bgErr.status = 'error';
if (sessionModule && sessionModule.clearStreaming) {
sessionModule.clearStreaming(streamSessionId);
}
}
}
if (!_isBgCatch) {
// Clean up any active spinner (e.g. "Generating response" during tool calls)
if (spinner && spinner.element) spinner.destroy();
_cancelThinkingTimer();
_removeThinkingSpinner();
document.querySelectorAll('#chat-history .agent-thread.streaming').forEach(t => t.classList.remove('streaming'));
// Stop streaming TTS on any error/abort
if (streamingTTS && window.aiTTSManager) window.aiTTSManager.stop();
if (abortCtrl && abortCtrl.signal.aborted) {
const abortReason = abortCtrl._reason || '';
// Timeout-triggered aborts should remain visible instead of disappearing.
if (timedOut || abortReason === 'timeout') {
const timeoutMsg = _isAgent
? 'Agent response timed out. Try again, switch to a faster model, or reduce tool usage.'
: 'Response timed out. Try again.';
if (holder && !accumulated) {
holder.querySelector('.body').innerHTML =
`
[${timeoutMsg}]
`;
} else if (_catchViewHolder && accumulated) {
const timeoutNote = document.createElement('div');
timeoutNote.className = 'stopped-indicator';
timeoutNote.innerHTML =
`[${timeoutMsg}]`;
_catchViewHolder.querySelector('.body').appendChild(timeoutNote);
}
if (currentAbort === abortCtrl) currentAbort = null;
return;
}
if (abortReason === 'offline') {
const offlineMsg = 'Endpoint offline — switch model or try again.';
if (holder && !accumulated) {
holder.querySelector('.body').innerHTML =
`
[${offlineMsg}]
`;
} else if (_catchViewHolder && accumulated) {
const offlineNote = document.createElement('div');
offlineNote.className = 'stopped-indicator';
offlineNote.innerHTML =
`[${offlineMsg}]`;
_catchViewHolder.querySelector('.body').appendChild(offlineNote);
}
if (currentAbort === abortCtrl) currentAbort = null;
return;
}
if (abortReason === 'recovery') {
const recoveryMsg = 'Streaming was interrupted after the tab went inactive. Partial output was preserved.';
if (holder && !accumulated) {
holder.querySelector('.body').innerHTML =
`
[${recoveryMsg}]
`;
} else if (_catchViewHolder && accumulated) {
const recoveryNote = document.createElement('div');
recoveryNote.className = 'stopped-indicator';
recoveryNote.innerHTML =
`[${recoveryMsg}]`;
_catchViewHolder.querySelector('.body').appendChild(recoveryNote);
}
if (currentAbort === abortCtrl) currentAbort = null;
return;
}
if (abortReason === 'stale-local') {
const staleMsg = 'Stream connection ended. Composer unlocked; send again if needed.';
if (holder && !accumulated) {
holder.querySelector('.body').innerHTML =
`
[${staleMsg}]
`;
} else if (_catchViewHolder && accumulated) {
const staleNote = document.createElement('div');
staleNote.className = 'stopped-indicator';
staleNote.innerHTML = `[${staleMsg}]`;
_catchViewHolder.querySelector('.body').appendChild(staleNote);
}
if (currentAbort === abortCtrl) currentAbort = null;
return;
}
// User-initiated stop (or browser navigation abort).
// Stopped before any text arrived — keep the bubble as a
// "Cancelled by user" record (so it survives a refresh).
if (holder && !accumulated) {
_renderCancelledBubble(holder);
}
// Navigation and non-button aborts do not pass through the synchronous
// Stop renderer. The catch render above owns markdown; add only the
// interruption controls here so each terminal path renders once.
if (_catchViewHolder && accumulated && currentHolder) {
_catchViewHolder.dataset.raw = accumulated;
const stoppedIndicator = document.createElement('div');
stoppedIndicator.className = 'stopped-indicator';
const stoppedLabel = document.createElement('span');
stoppedLabel.textContent = '[Message interrupted]';
stoppedIndicator.appendChild(stoppedLabel);
const continueBtn = document.createElement('button');
continueBtn.className = 'continue-btn resume-btn';
continueBtn.title = 'Resume response';
continueBtn.innerHTML = 'Resume';
continueBtn.addEventListener('click', () => {
stoppedIndicator.remove();
_hideUserBubble = true;
_pendingContinue = _catchViewHolder;
const msgInput = uiModule.el('message');
if (msgInput) {
msgInput.value = 'Continue from where you left off.';
const sb = document.querySelector('.send-btn');
if (sb) sb.click();
}
});
stoppedIndicator.appendChild(continueBtn);
_catchViewHolder.querySelector('.body').appendChild(stoppedIndicator);
// Tell server to mark this message as stopped
const _sid2 = sessionModule.getCurrentSessionId();
if (_sid2) fetch(`${API_BASE}/api/session/${_sid2}/mark-stopped`, { method: 'POST' }).catch(e => console.warn('mark-stopped failed:', e));
if (!_catchViewHolder.querySelector('.msg-footer')) {
_catchViewHolder.appendChild(createMsgFooter(_catchViewHolder));
}
uiModule.scrollHistory();
}
// Now clear the abort controller
if (currentAbort === abortCtrl) currentAbort = null;
} else {
console.error(err);
// Stream died with a tool node still spinning. Its per-node tickers
// (_elapsedTicker 50ms / _waveInterval 100ms) are normally cleared in
// `tool_output`, which will never arrive now — without this sweep they
// fire forever on the orphaned node (and auto-recover compounds it per
// nudge). Safe here: auto-recover's new send is deferred 200ms, so no
// fresh running nodes exist yet.
document.querySelectorAll('.agent-thread-node.running').forEach(node => {
if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; }
if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; }
node.classList.remove('running');
});
// Stream died unexpectedly — the "silently died" case. Re-engage the
// model immediately (no wait) with a completion handshake, up to the
// cap. Only auto-recover from connection-class failures; deterministic
// errors (unsupported tools, 4xx/5xx, parse failures) surface right away
// instead of burning the nudge budget on a guaranteed-to-fail retry.
if (!(isRecoverableStreamError(err) && _tryAutoRecover(_catchViewHolder, accumulated, streamSessionId))) {
if (err.terminalStreamError) {
if (_canonicalTerminalSaved) {
// Let this stream's finally block clear foreground state before
// reselecting; otherwise selectSession would detach the already
// terminal reader and leave a stale background-stream marker.
setTimeout(async () => {
if (sessionModule.getCurrentSessionId() === streamSessionId) {
await sessionModule.selectSession(streamSessionId, { showLoading: false });
} else {
await sessionModule.loadSessions();
}
}, 0);
} else {
// Streamed text is not evidence of a saved terminal record.
// Reloading here erases partial replies when an upstream
// failure left only the user message in server history.
// Preserve the live answer and append the escaped error.
const terminalBody =
_catchViewHolder?.querySelector('.body')
|| roundHolder?.querySelector('.body')
|| document.querySelector('.msg-ai:last-of-type .body');
if (terminalBody) {
const terminalNote = document.createElement('div');
terminalNote.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;';
terminalNote.textContent = `[Error: ${err.message}]`;
terminalBody.appendChild(terminalNote);
}
}
return;
}
const errorHolder =
_catchViewHolder?.querySelector('.body')
|| document.querySelector('.msg-ai:last-of-type .body');
if (errorHolder) {
let errMsg = `Error: ${err.message}`;
// Add hint for tool-call errors
if (err.message && (err.message.includes('tool') || err.message.includes('auto'))) {
errMsg += '\n\nThis model may not support tools — try switching to Chat mode.';
}
typewriterInto(errorHolder, errMsg);
}
}
}
}
} finally {
_editorProgress = null;
document.querySelectorAll('.agent-finish-editor').forEach(button => button.remove());
_settleTurnRendering();
_cancelLiveThinkingWork();
clearResponseTimeout();
clearProcessingProbe();
clearFirstTokenWaitTimers();
_stopTtftDisplay();
// A replacement send bumps the session's generation the moment it
// starts, before it registers or reaches the server, so cleanup rights
// are decided by generation: a superseded send may remove only what it
// itself owns (its stream registration by controller identity, its own
// generation's queued Stop) and must leave session-level state — the
// reader session id, research marker, UI — to the replacement.
const _ownsStreamState =
_streamGenerations.get(streamSessionId) === streamGeneration;
if (_ownsStreamState && sessionModule.getCurrentSessionId() === streamSessionId) {
documentModule?.streamDocFinalize?.();
}
if (_ownsStreamState && _streamSawDone) {
sessionModule.markStreamComplete?.(streamSessionId);
} else if (_ownsStreamState && abortCtrl?._reason === 'user-stop') {
sessionModule.clearStreaming?.(streamSessionId);
}
const _finallyRegistered = _activeStreams.get(streamSessionId);
if (!_finallyRegistered || _finallyRegistered.abortCtrl === abortCtrl) {
_activeStreams.delete(streamSessionId);
}
_pendingRunStops.delete(streamSessionId + ':' + streamGeneration);
if (_ownsStreamState) {
if (_streamSessionId === streamSessionId) _streamSessionId = null;
if (_sendStates.get(streamSessionId) === _sendState) {
_sendStates.delete(streamSessionId);
}
// Superseded sends must not resync: with the replacement not yet
// registered, a stale sync would set isStreaming false and drop
// currentAbort while _sendInFlight is already false, reopening the
// send gate mid-preflight. The replacement syncs when it registers
// or finishes.
_syncForegroundStreamGlobals();
}
// Streaming done — let screen readers announce the settled response.
if (_ownsStreamState) {
const _chatLogDone = document.getElementById('chat-history');
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
}
// Research markers gate /api/research/cancel in the Stop handler, so a
// superseded send must not strip a replacement research run's marker.
if (_ownsStreamState) _researchingStreamIds.delete(streamSessionId);
if (_researchingStreamIds.size === 0) {
var _rToggleCleanup = document.getElementById('research-toggle-btn');
if (_rToggleCleanup) _rToggleCleanup.classList.remove('research-running');
}
// Only reset UI state if still on the stream's session, never
// backgrounded, and no replacement stream owns the session now — the
// replacement disabled the composer for its own send, so re-enabling
// it here would hand input back mid-stream.
const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
if (_ownsStreamState) _terminalSavedStreams.delete(streamSessionId);
if (!_isBgFinally && _ownsStreamState) {
// Reset button to idle state
updateSubmitButton('idle', submitBtn);
// Re-enable message input; on mobile blur to dismiss keyboard
if (messageInput) {
messageInput.disabled = false;
if (window.innerWidth <= 768) {
messageInput.blur();
} else if (!document.getElementById('doc-editor-pane')) {
// Do not steal focus from an open document at stream completion.
// preventScroll also keeps the chat viewport stable in browsers
// that reveal a focused input even when it is already visible.
try { messageInput.focus({ preventScroll: true }); }
catch (_) { messageInput.focus(); }
}
}
// Clear tracking variables
currentAccumulated = '';
currentHolder = null;
currentSpinner = null;
_researchingStreamIds.delete(streamSessionId);
// Clear research-running highlight if no more active research
if (_researchingStreamIds.size === 0) {
var _rToggle2 = document.getElementById('research-toggle-btn');
if (_rToggle2) _rToggle2.classList.remove('research-running');
}
_clearResearchTimer();
// Re-enable research button and auto-untoggle after use
// (skip if clarification round — keep toggle on for follow-up)
const _el = uiModule.el;
const _researchBtn = _el('research-toggle-btn');
const _researchToggle = _el('research-toggle');
if (_researchToggle && _researchToggle.checked) {
_researchToggle.checked = false;
Storage.setToggle('research', false);
}
if (_researchBtn) {
_researchBtn.disabled = false;
_researchBtn.classList.remove('active');
_researchBtn.style.display = 'none';
}
// Also sync overflow and tool sidebar buttons
const _overflowRes = _el('overflow-research-btn');
if (_overflowRes) _overflowRes.classList.remove('active');
const _toolRes = _el('tool-research-btn');
if (_toolRes) _toolRes.classList.remove('active');
}
// Research clarification timeout — if user doesn't reply within 5 min, show timeout
if (holder && holder._roleSuffix === 'Research' && !_researchingStreamIds.has(streamSessionId)) {
var _timeoutSessionId = streamSessionId;
var _timeoutTimer = setTimeout(async function() {
// Check if research_pending is still active (user hasn't replied)
try {
var _box = document.getElementById('chat-history');
if (_box && sessionModule.getCurrentSessionId() === _timeoutSessionId) {
var _timeoutMsg = document.createElement('div');
_timeoutMsg.className = 'msg msg-ai';
_timeoutMsg.innerHTML = '
Odysseus
Research clarification timed out. Toggle research again to start over.
';
_box.appendChild(_timeoutMsg);
uiModule.scrollHistory();
}
} catch(_te) {}
}, 5 * 60 * 1000);
// Cancel timeout if user sends a message
var _origSubmit = window._researchTimeoutTimer;
if (_origSubmit) clearTimeout(_origSubmit);
window._researchTimeoutTimer = _timeoutTimer;
}
// Release Web Lock
if (_webLockRelease) {
_webLockRelease();
_webLockRelease = null;
}
// Refresh session list after a delay (picks up auto-generated names)
setTimeout(() => {
if (sessionModule && sessionModule.loadSessions) {
sessionModule.loadSessions();
}
}, 3000);
_drainQueuedAgentRequests();
}
}
/**
* Abort current chat request
*/
// stopServer=true ONLY for an explicit user Stop. The run is now DETACHED
// (survives tab close / navigation), so the generic abort used by cleanup
// paths (session switch, delete, reader teardown on tab close) must NOT stop
// the server run — otherwise closing the tab would kill the background task,
// defeating the whole point. Only the Stop button cancels the server run.
export function abortCurrentRequest(stopServer = false) {
const _sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId())
|| _streamSessionId
|| (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId());
// The CURRENT send's controller comes from its send state, installed at
// send commit — never borrowed from the stream registry, which during the
// replacement's preflight still holds the superseded send's entry.
// Aborting that older controller here would sever the only identity
// channel able to name the old run. A send committed but pre-POST has a
// null controller: the Stop queues and there is nothing to abort yet.
const _sendStateNow = _sid ? _sendStates.get(_sid) : null;
const active = _getForegroundStreamState();
const abortCtrl = _sendStateNow
? _sendStateNow.abortCtrl
: (active ? active.abortCtrl : currentAbort);
let abortNow = true;
if (stopServer) {
try {
if (_sid) {
// Before response headers arrive there is no safe server-side stop
// identity yet. Keep the POST alive just long enough to receive that
// opaque id, then _rememberStreamRunId sends the exact Stop and aborts
// this reader. Never fall back to a headerless session-wide cancel.
abortNow = _stopExactRun(_sid, abortCtrl);
}
} catch (_) {}
}
if (abortCtrl && abortNow) {
abortCtrl.abort();
// Don't set to null here - let catch block handle it
}
}
// ── Stall watchdog ──────────────────────────────────────────────
// Auto-recover a turn whose browser stream died by reconnecting to the exact
// detached server run. Returns false at the cap so the caller can surface
// the failure instead of retrying forever.
// Only auto-recover from connection-class failures (the genuine "silently
// died" case). Deterministic errors — unsupported tools, HTTP 4xx/5xx, JSON
// parse failures — will fail identically on retry, so surfacing them
// immediately is both more honest and avoids wasting the nudge budget.
function _tryAutoRecover(holder, accumulated, sessionId) {
if (_autoNudges >= _AUTO_NUDGE_CAP) return false;
_autoNudges++;
if (holder && accumulated) {
holder.dataset.raw = accumulated;
}
// The server run is detached and keeps its exact pinned model/tool state.
// Reconnect to that run instead of submitting a new user turn, which would
// cancel it, retry the selected model, and risk duplicating side effects.
setTimeout(async () => {
// The stream that died may not be the chat the user is now looking at —
// never attach the recovery reader to the wrong conversation.
if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) return;
const resumed = await resumeStream(sessionId, holder || null);
if (!resumed && holder && holder.isConnected) {
const body = holder.querySelector('.body');
if (body) typewriterInto(body, 'Connection lost. The existing run could not be resumed.');
}
}, 200);
return true;
}
function _removeStallBanner() {
const b = document.getElementById('stall-banner');
if (b) b.remove();
_stallBannerShown = false;
}
function _showStallBanner(secs) {
if (document.getElementById('stall-banner')) return;
_stallBannerShown = true;
const box = document.getElementById('chat-history');
if (!box) return;
const bar = document.createElement('div');
bar.id = 'stall-banner';
bar.className = 'stall-banner';
const mins = Math.floor(secs / 60);
const label = mins >= 1 ? `${mins}m` : `${secs}s`;
bar.innerHTML = `Quiet for ${label} — still working?`;
const cont = document.createElement('button');
cont.className = 'stall-banner-btn';
cont.textContent = 'Nudge it';
cont.title = 'Stop the stalled stream and ask it to continue';
cont.addEventListener('click', () => {
_removeStallBanner();
const mi = uiModule.el('message');
if (mi) {
mi.value = 'Are you still working? If you stopped, continue exactly where you left off and finish the task.';
const sb = document.querySelector('.send-btn');
if (sb) sb.click();
}
});
const stop = document.createElement('button');
stop.className = 'stall-banner-btn stall-banner-stop';
stop.textContent = 'Stop';
stop.addEventListener('click', () => { _removeStallBanner(); abortCurrentRequest(true); });
bar.appendChild(cont);
bar.appendChild(stop);
box.appendChild(bar);
if (uiModule.scrollHistory) uiModule.scrollHistory();
}
async function _probeStaleLocalStream() {
const active = _getForegroundStreamState();
if (!active || _staleStreamProbeInFlight) return;
if (Date.now() - (active.lastActivity || _lastReaderActivity) < STALE_LOCAL_STREAM_MS) return;
const sid = sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId();
if (!sid) return;
if (_backgroundStreams.has(sid) || (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() !== sid)) return;
_staleStreamProbeInFlight = true;
try {
const res = await fetch(`${API_BASE}/api/chat/stream_status/${encodeURIComponent(sid)}`, {
credentials: 'same-origin',
cache: 'no-store',
});
if (!_getForegroundStreamState() || _backgroundStreams.has(sid)) return;
// The server now reports "no active stream" as 200 {status:"idle"} (an
// older server used 404); a body with status "streaming" means it is NOT
// stale. Accept both so the watchdog keeps working across deploys.
let _probe = null;
try { _probe = await res.json(); } catch (_) { _probe = null; }
const _noActiveStream = res.status === 404 || !_probe || _probe.status !== 'streaming';
if (!_noActiveStream) return;
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
if (active.abortCtrl && !active.abortCtrl.signal.aborted) {
active.abortCtrl._reason = 'stale-local';
active.abortCtrl.abort();
}
_activeStreams.delete(sid);
_syncForegroundStreamGlobals();
_sendInFlight = false;
if (_webLockRelease) {
_webLockRelease();
_webLockRelease = null;
}
const submitBtn = document.querySelector('.send-btn');
if (submitBtn) updateSubmitButton('idle', submitBtn);
const messageInput = uiModule.el('message');
if (messageInput) messageInput.disabled = false;
_drainQueuedAgentRequests();
} catch (err) {
console.warn('[stream-watchdog] Stream status probe failed:', err);
} finally {
_staleStreamProbeInFlight = false;
}
}
function _startStallWatchdog() {
// Keep the old noisy stall banner disabled. This watchdog only unlocks
// a dead local stream after the backend confirms no active stream exists.
if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; }
_removeStallBanner();
_stallWatchdog = setInterval(_probeStaleLocalStream, 5000);
}
function _stopStallWatchdog() {
if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; }
_removeStallBanner();
}
/** Show a "Cancelled by user" record in `holder` and persist an empty
* assistant placeholder server-side so the turn survives a refresh.
* Called from both abort paths when no tokens had streamed yet. */
function _renderCancelledBubble(holder) {
if (!holder) return;
if (holder.dataset.cancelledRendered === '1') return;
holder.dataset.cancelledRendered = '1';
holder.dataset.raw = '';
holder.style.display = '';
const body = holder.querySelector('.body');
if (body) {
body.innerHTML = '';
const indicator = document.createElement('div');
indicator.className = 'stopped-indicator';
const label = document.createElement('span');
label.style.fontStyle = 'italic';
label.style.opacity = '0.7';
label.textContent = '[Cancelled by user]';
indicator.appendChild(label);
body.appendChild(indicator);
}
if (typeof createMsgFooter === 'function' && !holder.querySelector('.msg-footer')) {
holder.appendChild(createMsgFooter(holder));
}
// Persist as an assistant message with stopped+cancelled metadata so the
// chat-history loader renders the same indicator after a refresh.
// Include the model name so the bubble header still shows which model
// was running when the user hit Stop.
const sid = sessionModule.getCurrentSessionId();
if (sid) {
let modelName = '';
try { modelName = sessionModule.getCurrentModel?.() || ''; } catch {}
// Fallback: pull from the holder's existing meta (the streaming
// placeholder usually has the model set in the header already).
if (!modelName) {
modelName = holder.dataset.model
|| holder.querySelector('.msg-header .msg-model')?.textContent
|| '';
}
fetch(`${API_BASE}/api/session/${sid}/inject_messages`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [{
role: 'assistant',
content: '',
metadata: { stopped: true, cancelled: true, model: modelName },
}],
}),
}).catch(() => {});
}
}
/**
* Detach current stream to run in background instead of aborting.
* Called when user switches sessions mid-stream.
*/
export function detachCurrentStream(sessionId) {
const active = sessionId ? _activeStreams.get(sessionId) : _getForegroundStreamState();
if (!active || !active.abortCtrl) {
// Not streaming — fall through to abort
abortCurrentRequest();
return;
}
// The backend owns the detached run. Stop this tab's subscriber and its
// delayed rendering; session re-entry will replay and follow the same run.
if (active.cancelViewWork) active.cancelViewWork();
const terminalSaved = _terminalSavedStreams.has(sessionId);
// Store background stream state. A canonical terminal event can precede
// its SSE error event; preserve completion if the user switches sessions
// during that gap instead of creating a fresh running/error marker.
_backgroundStreams.set(sessionId, {
status: terminalSaved ? 'completed' : 'running',
accumulated: currentAccumulated,
sourcesHtml: '',
findingsData: null,
abortCtrl: active.abortCtrl,
query: active.query || (active.holder ? (active.holder._researchQuery || '') : ''),
metrics: null,
});
// Mark session with pulsing dot in sidebar
if (!terminalSaved && sessionModule && sessionModule.markStreaming) {
sessionModule.markStreaming(sessionId);
} else if (terminalSaved && sessionModule && sessionModule.clearStreaming) {
sessionModule.clearStreaming(sessionId);
}
active.abortCtrl._reason = 'detach';
_activeStreams.delete(sessionId);
if (!active.abortCtrl.signal.aborted) active.abortCtrl.abort();
// Clear foreground state without cancelling the server-owned run.
if (currentAbort === active.abortCtrl) currentAbort = null;
if (currentHolder === active.holder) currentHolder = null;
if (_streamSessionId === sessionId) _streamSessionId = null;
currentAccumulated = '';
_syncForegroundStreamGlobals();
// Reset submit button so the new chat is ready to send
const submitBtn = document.querySelector('.send-btn');
if (submitBtn) updateSubmitButton('idle', submitBtn);
}
// _notifyStreamComplete and _insertStreamDoneToast now in chatStream.js
var _notifyStreamComplete = chatStream.notifyStreamComplete;
var _insertStreamDoneToast = chatStream.insertStreamDoneToast;
/**
* Live-resume a chat run still streaming detached on the server (#2539).
*
* On session re-entry, GET /api/chat/resume/{id} replays the run's buffer then
* streams live; reply tokens render as they arrive. On completion a plain text
* reply is finalized in place (canonical bubble via chatRenderer.addMessage, no
* reload); a "rich" reply (tool calls, sources, doc streaming, multi-round) is
* reloaded from the DB so its full render stays faithful. Returns true if it
* attached, false to let the caller fall back to spinner+poll.
*/
export async function resumeStream(sessionId, replaceHolder = null) {
if (!sessionId) return false;
if (hasActiveStream(sessionId)) return false;
let res;
try {
res = await fetch(`${API_BASE}/api/chat/resume/${sessionId}`);
} catch (e) {
return false;
}
if (!res.ok || !res.body) return false;
const resumeRunId = res.headers.get('X-Odysseus-Run-Id') || '';
if (resumeRunId) _streamRunIds.set(sessionId, resumeRunId);
const box = document.getElementById('chat-history');
if (!box) return false;
if (replaceHolder && replaceHolder.parentNode) replaceHolder.remove();
// Block duplicate re-attach attempts while this reader is live. A dedicated
// set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this
// for a same-tab POST stream and spawn its own spinner+poll on re-entry.
_resumingStreams.add(sessionId);
_backgroundStreams.delete(sessionId);
const submitBtn = document.querySelector('.send-btn');
if (submitBtn) updateSubmitButton('streaming', submitBtn);
if (sessionModule && sessionModule.markStreaming) sessionModule.markStreaming(sessionId);
const holder = document.createElement('div');
holder.className = 'msg msg-ai';
const meta = sessionModule.getSessions().find(s => s.id === sessionId);
const roleLabel = _shortModel(meta && meta.model);
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
holder.innerHTML = '
' + uiModule.esc(roleLabel) +
' ' + roleTs + '
' +
'
';
holder._requestedModel = meta && meta.model;
holder._actualModel = holder._requestedModel;
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
let roundHolder = holder;
let contentDiv = holder.querySelector('.stream-content');
const replayMarker = document.createComment('live-stream-turn');
box.appendChild(replayMarker);
const replayRendering = createTurnRendering({ root: box, start: replayMarker });
box.appendChild(holder);
const replayNodes = [holder];
const spinner = spinnerModule.create('Generating response...', 'right');
holder.querySelector('.body').appendChild(spinner.createElement());
spinner.start();
uiModule.scrollHistory();
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let roundText = '';
let replayThinking = '';
let replayMessageId = '';
let docFenceOpened = false;
let gotDelta = false;
let leftSession = false;
let metricsData = null;
let replayError = null;
let canonicalTerminalSeen = false;
let currentToolBubble = null;
let currentToolThread = null;
// "Rich" responses (tool calls, sources, doc streaming, multi-round) need the
// full canonical render, which is rebuilt from the saved DB record on reload.
// Plain text replies can be finalized in place without a reload.
let rich = false;
const cleanup = () => {
replayRendering.settle();
try { spinner.destroy(); } catch (_) {}
_resumingStreams.delete(sessionId);
if (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() === sessionId) {
if (submitBtn) updateSubmitButton('idle', submitBtn);
if (sessionModule && sessionModule.clearStreaming) sessionModule.clearStreaming(sessionId);
const input = uiModule.el && uiModule.el('message');
if (input) input.disabled = false;
}
};
const renderDelta = (event = {}, scroll = true) => {
if (!roundHolder.isConnected || roundHolder.style.display === 'none'
|| (event.type === 'final_response' && currentToolThread?.isConnected
&& (roundHolder.compareDocumentPosition(currentToolThread) & Node.DOCUMENT_POSITION_FOLLOWING))) {
const text = roundText;
const thinking = replayThinking;
startReplayRound();
roundText = text;
replayThinking = thinking;
}
contentDiv = roundHolder.querySelector('.body');
const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText, { final: docFenceOpened }));
if (docFenceOpened && !dt.trim()) {
_showDocumentWritingStatus(contentDiv);
} else {
const source = (replayThinking ? `${replayThinking}` : '') + markdownModule.squashOutsideCode(dt);
const html = markdownModule.processWithThinking
? markdownModule.processWithThinking(source)
: markdownModule.mdToHtml(source);
replayRendering.render({ body: contentDiv, html, raw: roundText, messageId: replayMessageId,
render_owner: event.render_owner, replacement_scope: event.replacement_scope });
}
if (scroll) uiModule.scrollHistory();
};
const removeReplayNodes = () => {
replayNodes.forEach(node => { if (node && node.parentNode) node.remove(); });
replayMarker.remove();
};
const startReplayRound = () => {
if (!roundText.trim() && roundHolder) roundHolder.style.display = 'none';
if (currentToolThread) currentToolThread.classList.add('has-bottom');
roundText = '';
replayThinking = '';
const next = document.createElement('div');
next.className = 'msg msg-ai msg-continuation streaming';
next.innerHTML = '
';
if (currentToolThread) currentToolThread.classList.remove('streaming');
};
try {
readLoop:
while (true) {
// User left this session: stop rendering, the run continues server-side.
if (sessionModule.getCurrentSessionId &&
sessionModule.getCurrentSessionId() !== sessionId) {
leftSession = true;
try { await reader.cancel(); } catch (_) {}
break;
}
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop();
for (const part of parts) {
const eventIsError = part.split('\n').some(l => l.trim() === 'event: error');
if (eventIsError) rich = true;
const line = part.split('\n').find(l => l.startsWith('data: '));
if (!line) continue;
const payload = line.slice(6);
if (payload === '[DONE]') {
try { await reader.cancel(); } catch (_) {}
break readLoop;
}
let json;
try { json = JSON.parse(payload); } catch (_) { continue; }
if (eventIsError || ['stable', 'complete', 'error', 'agent_terminal', 'chat_terminal'].includes(json.type)) replayRendering.settle();
if (eventIsError) {
replayError = createTerminalStreamError(json);
} else if (json.type === 'final_response') {
const finalText = String(json.content || json.delta || '');
if (!finalText.trim()) continue;
if (!replayRendering.accepts(json)) continue;
roundText = finalText;
gotDelta = true;
try { spinner.destroy(); } catch (_) {}
renderDelta(json);
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
replayRendering.settle();
} else if (json.delta) {
if (!replayRendering.accepts(json)) continue;
if (!json.thinking && json.render_owner === 'streamed' && json.replacement_scope === 'turn') {
roundText = '';
}
if (json.thinking) replayThinking += json.delta;
else
roundText += json.delta;
if (!docFenceOpened && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
docFenceOpened = true;
rich = true;
}
if (!gotDelta) { gotDelta = true; try { spinner.destroy(); } catch (_) {} }
renderDelta(json);
} else if (json.type === 'doc_stream_open') {
rich = true;
if (documentModule) documentModule.streamDocOpen(json.title || '', json.lang || '');
} else if (json.type === 'doc_stream_delta') {
rich = true;
if (documentModule) documentModule.streamDocDelta(json.content || json.delta || '');
} else if (json.type === 'metrics') {
metricsData = json.data || metricsData;
if (metricsData && resumeRunId) {
metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);
}
if (metricsData) {
chatRenderer.recordSessionMetricsCost(metricsData, sessionId);
}
} else if (json.type === 'message_saved') {
replayMessageId = String(json.id || '');
} else if (json.type === 'fallback') {
// Replay can attach after the selected route has already failed.
// Reflect the fallback immediately, then reload the canonical
// multi-round record when the detached run completes.
rich = true;
const fallbackHolder = applyModelRouteEventState(json, holder, null, meta && meta.model);
if (fallbackHolder) {
_setRoleModelLabel(
fallbackHolder.querySelector('.role'),
fallbackHolder._requestedModel,
fallbackHolder._actualModel,
{
reason: json.reason,
requestedEndpointId: fallbackHolder._requestedEndpointId,
requestedEndpointLabel: fallbackHolder._requestedEndpointLabel,
actualEndpointId: fallbackHolder._actualEndpointId,
actualEndpointLabel: fallbackHolder._actualEndpointLabel,
},
);
}
uiModule.showToast(
'Fallback: ' + _shortModel(json.selected_model || '') + ' failed — answered by ' +
_shortModel(json.answered_by || ''),
6000,
);
} else if (json.type === 'model_actual') {
rich = true;
const modelHolder = applyModelRouteEventState(json, holder, null, meta && meta.model);
if (modelHolder) {
_setRoleModelLabel(
modelHolder.querySelector('.role'),
modelHolder._requestedModel,
modelHolder._actualModel,
{
requestedEndpointId: modelHolder._requestedEndpointId,
requestedEndpointLabel: modelHolder._requestedEndpointLabel,
actualEndpointId: modelHolder._actualEndpointId,
actualEndpointLabel: modelHolder._actualEndpointLabel,
},
);
}
} else if (json.type === 'agent_terminal' || json.type === 'chat_terminal') {
// The server has already persisted canonical partial content plus
// a sanitized failure note and actual route provenance. Do not
// finalize replayed deltas as a successful local-only answer.
rich = true;
canonicalTerminalSeen = true;
metricsData = json.data || metricsData;
if (metricsData && resumeRunId) {
metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);
}
if (metricsData) displayMetrics(holder, metricsData);
} else if (json.type === 'tool_start' || json.type === 'tool_output' ||
json.type === 'tool_progress' || json.type === 'agent_step' ||
json.type === 'web_sources' || json.type === 'rag_sources' ||
json.type === 'research_progress' || json.type === 'research_sources' ||
json.type === 'research_findings' || json.type === 'research_done') {
rich = true;
if (json.type === 'tool_start') {
startReplayTool(json);
} else if (json.type === 'tool_output') {
finishReplayTool(json);
} else if (json.type === 'tool_progress' && currentToolBubble) {
const target = currentToolBubble.querySelector('.agent-thread-content');
if (target && (json.tail || json.message)) {
let progress = target.querySelector('.agent-thread-tail');
if (!progress) {
progress = document.createElement('pre');
progress.className = 'agent-thread-tail';
target.appendChild(progress);
}
progress.textContent = String(json.tail || json.message || '');
}
} else if (json.type === 'agent_step') {
if (replayRendering.accepts(json) && startsContinuationRound(json)) startReplayRound();
}
}
}
}
} catch (e) {
// Network drop or parse failure: fall through to the canonical reload.
rich = true;
}
cleanup();
if (docFenceOpened) _finishDocumentWritingStatus(holder, true);
if (leftSession) { removeReplayNodes(); return true; }
const onThisSession = sessionModule.getCurrentSessionId &&
sessionModule.getCurrentSessionId() === sessionId;
// A failure before substantive output has no persisted assistant record to
// recover through a canonical reload. Keep its sanitized provider/request
// error visible in the replay holder instead of deleting the only evidence.
if (onThisSession && replayError && !canonicalTerminalSeen) {
const errorDiv = document.createElement('div');
errorDiv.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;';
errorDiv.textContent = `[Error: ${replayError.message}]`;
contentDiv.appendChild(errorDiv);
uiModule.scrollHistory();
return true;
}
const isReplayCurrent = () => sessionModule.getCurrentSessionId() === sessionId
&& replayMarker.parentNode === box
&& (!resumeRunId || _streamRunIds.get(sessionId) === resumeRunId);
if (!onThisSession || !isReplayCurrent()) { removeReplayNodes(); return true; }
const scrollSnapshot = uiModule.captureHistoryScroll?.();
// Reconcile only this saved answer. Rebuilding history would discard open
// tool cards and resurrect drafts in clients with older history renderers.
if (replayMessageId) {
try {
const savedResponse = await fetch(`${API_BASE}/api/history/${encodeURIComponent(sessionId)}?limit=12`);
if (savedResponse.ok && isReplayCurrent()) {
const data = await savedResponse.json();
if (!isReplayCurrent()) { removeReplayNodes(); return true; }
const saved = (data.history || []).find(message => message.role === 'assistant'
&& String(message.metadata?._db_id || '') === replayMessageId);
if (saved) {
metricsData = { ...metricsData, ...saved.metadata };
roundText = String(saved.content || '');
if (roundText.trim()) renderDelta(saved.metadata || {}, false);
}
}
} catch (error) {
console.warn('Could not reconcile resumed answer:', error);
}
}
if (!isReplayCurrent()) { removeReplayNodes(); return true; }
const target = roundHolder?.isConnected && roundHolder.style.display !== 'none'
? roundHolder : currentToolThread;
if (target) {
target.classList.remove('streaming');
if (replayMessageId) target.dataset.dbId = replayMessageId;
if (target.classList.contains('msg-ai')) {
target.dataset.raw = roundText;
if (!target.querySelector('.msg-footer')) target.appendChild(createMsgFooter(target));
const body = target.querySelector('.body');
if (body && !body.querySelector('.sources-section')) {
const sources = metricsData?.web_sources || metricsData?.research_sources;
if (sources?.length) body.insertAdjacentHTML('afterbegin', chatRenderer.buildSourcesBox(sources, metricsData.web_sources ? 'web' : 'research'));
if (metricsData?.research_findings?.length) body.insertAdjacentHTML('beforeend', chatRenderer.buildFindingsBox(metricsData.research_findings));
}
if (body && metricsData?.rag_sources?.length && !body.querySelector('.rag-sources')) {
body.insertAdjacentHTML('beforeend', chatRenderer.buildRagSourcesBox(metricsData.rag_sources));
}
}
if (metricsData) displayMetrics(target, metricsData);
}
replayRendering.settle();
replayMarker.remove();
uiModule.restoreHistoryScroll?.(scrollSnapshot);
return true;
}
/**
* Check for background streams when switching to a session.
* Called after history loads on session switch.
*/
export function checkBackgroundStream(sessionId) {
if (!sessionId || !_backgroundStreams.has(sessionId)) return;
var entry = _backgroundStreams.get(sessionId);
if (entry.status === 'completed') {
// Response is already saved to DB and will appear in history — just clean up
_backgroundStreams.delete(sessionId);
_syncForegroundStreamGlobals();
return;
}
if (entry.status === 'error') {
_backgroundStreams.delete(sessionId);
_syncForegroundStreamGlobals();
var box = document.getElementById('chat-history');
if (box) {
var errHolder = document.createElement('div');
errHolder.className = 'msg msg-ai';
errHolder.innerHTML = '
[Background stream encountered an error]
';
box.appendChild(errHolder);
}
return;
}
if (entry.status === 'running') {
// The local subscriber was intentionally detached. Remove the local
// marker so sessions.js can attach /api/chat/resume and render the
// buffered structured events from the server.
_backgroundStreams.delete(sessionId);
_syncForegroundStreamGlobals();
}
}
// Tag short single-line code blocks with .pre-compact so the CSS can
// render the Run/Edit/Copy buttons as a slim row that doesn't make a
// 1-line bash block taller than its own contents.
function _markCompactPre(pre) {
const code = pre.querySelector('code');
if (!code) return;
const txt = code.textContent || '';
// Count visible lines — ignore trailing newline (common with fenced
// blocks) and treat any empty extra line as not a real second line.
const lines = txt.replace(/\n+$/, '').split('\n');
const compact = lines.length <= 1 && txt.length < 200;
pre.classList.toggle('pre-compact', compact);
}
function _scanCompactPres(root) {
if (!root || !root.querySelectorAll) return;
root.querySelectorAll('pre').forEach(_markCompactPre);
}
// Global observer so any
added anywhere in the app (chat stream,
// chat re-renders, document library chat previews, slash commands,
// research previews, etc.) gets tagged without each call site needing
// to remember.
(function _initCompactPreObserver() {
if (window._cmpPreObserverWired) return;
window._cmpPreObserverWired = true;
_scanCompactPres(document.body);
const obs = new MutationObserver((muts) => {
for (const m of muts) {
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.tagName === 'PRE') _markCompactPre(n);
if (n.querySelectorAll) _scanCompactPres(n);
}
}
});
obs.observe(document.body, { childList: true, subtree: true });
})();
/**
* Initialize event listeners
*/
export function initListeners() {
// Global event delegation for copy-code buttons
document.addEventListener('click', (e) => {
const btn = e.target.closest('.copy-code');
if (!btn) return;
e.stopPropagation();
const code = btn.getAttribute('data-code');
if (code && uiModule) {
uiModule.copyToClipboard(code);
// Visual feedback: swap the icon to a checkmark (regular size)
// and add .copied which the CSS uses to flash green + pulse.
// For slim/.pre-compact buttons the label text comes from a
// CSS ::before — swap it via data-state so we don't break the
// text-button layout.
const origHTML = btn.innerHTML;
const isCompact = !!btn.closest('pre.pre-compact');
if (!isCompact) {
btn.innerHTML = '';
}
btn.classList.add('copied');
btn.dataset.state = 'copied';
setTimeout(() => {
if (!isCompact) btn.innerHTML = origHTML;
btn.classList.remove('copied');
delete btn.dataset.state;
}, 1500);
}
});
// Run code button delegation
document.addEventListener('click', (e) => {
const btn = e.target.closest('.run-code');
if (!btn) return;
e.stopPropagation();
if (codeRunnerModule) codeRunnerModule.run(btn);
});
// Edit code button delegation — toggle contentEditable on the code element
document.addEventListener('click', (e) => {
const btn = e.target.closest('.edit-code');
if (!btn) return;
e.stopPropagation();
const pre = btn.closest('pre');
if (!pre) return;
const codeEl = pre.querySelector('code');
if (!codeEl) return;
const isEditing = codeEl.contentEditable !== 'false' && codeEl.contentEditable !== 'inherit';
if (isEditing) {
// Save: exit edit mode, update data-code on copy/run buttons
codeEl.contentEditable = 'false';
codeEl.classList.remove('editing');
pre.classList.remove('editing');
const newCode = codeEl.textContent;
const copyBtn = pre.querySelector('.copy-code');
if (copyBtn) copyBtn.setAttribute('data-code', newCode);
const runBtn = pre.querySelector('.run-code');
if (runBtn) runBtn.setAttribute('data-code', newCode);
// Swap icon back to pencil
btn.innerHTML = '';
btn.title = 'Edit';
btn.classList.remove('active');
} else {
// Enter edit mode. Firefox (especially on mobile) historically lacks
// contentEditable="plaintext-only" — setting it there leaves the block
// non-editable, so the tap "just gets a checkmark" with no way to type.
// Fall back to "true" when plaintext-only didn't take.
try { codeEl.contentEditable = 'plaintext-only'; } catch (_) { /* unsupported value */ }
if (codeEl.contentEditable !== 'plaintext-only') codeEl.contentEditable = 'true';
codeEl.classList.add('editing');
pre.classList.add('editing');
// preventScroll keeps the page from jumping to the codeblock when
// focusing the editable on mobile — the browser would otherwise
// scroll it into view above the keyboard, which reads as "auto-
// scroll triggered by clicking Edit".
try { codeEl.focus({ preventScroll: true }); } catch (_) { codeEl.focus(); }
// Swap icon to checkmark
btn.innerHTML = '';
btn.title = 'Done editing';
btn.classList.add('active');
}
});
// Tapping a code block body (not its buttons) toggles the overlay
// copy/edit/run buttons, which otherwise cover the text on mobile.
document.addEventListener('click', (e) => {
if (e.target.closest('.copy-code, .edit-code, .run-code')) return;
const pre = e.target.closest('pre');
if (!pre || !pre.querySelector('.copy-code')) return;
// Don't hide while editing — the buttons (incl. the Done checkmark) matter.
if (pre.classList.contains('editing')) return;
pre.classList.toggle('buttons-hidden');
});
// Position copy/run buttons top or bottom based on viewport position
// — DESKTOP ONLY. On mobile this was constantly retriggering on tap
// (synthetic mouseenter) and made the buttons jump, so the user's
// finger landed on the moved target. Keep them pinned at the top on
// touch — no auto-repositioning.
document.addEventListener('mouseenter', (e) => {
if (window.matchMedia('(max-width: 768px)').matches) return;
const pre = e.target.closest ? e.target.closest('pre') : null;
if (!pre || pre.dataset.btnPosComputed) return;
const rect = pre.getBoundingClientRect();
const threshold = window.innerHeight * 0.35;
const isBottom = rect.top < threshold;
const copyBtn = pre.querySelector('.copy-code');
if (copyBtn) copyBtn.classList.toggle('bottom', isBottom);
const editBtn = pre.querySelector('.edit-code');
if (editBtn) editBtn.classList.toggle('bottom', isBottom);
const runBtn = pre.querySelector('.run-code');
if (runBtn) runBtn.classList.toggle('bottom', isBottom);
pre.dataset.btnPosComputed = '1';
}, true);
// A hidden browser tab may throttle timers and delivery for an arbitrary
// amount of time. The detached backend run is authoritative, so becoming
// visible must never abort an otherwise healthy run. Buffered SSE data will
// catch up naturally; genuine connection failures use resumeStream(), and
// the status probe only unlocks the composer when the server confirms that
// no run exists.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') {
for (const active of _activeStreams.values()) active.wasAway = true;
return;
}
_restoreQueuedRequestsForCurrentSession();
_probeStaleLocalStream().then(() => {
_drainQueuedAgentRequests();
}).catch(err => {
console.warn('[tab-recovery] Server status probe failed:', err);
});
});
// On mobile, fade out welcome text when keyboard opens to prevent overlap
if (window.innerWidth <= 768) {
const msgInput = document.getElementById('message');
if (msgInput) {
msgInput.addEventListener('focus', () => {
const ws = document.getElementById('welcome-screen');
if (ws && !ws.classList.contains('hidden')) {
ws.classList.add('kb-hidden');
}
});
msgInput.addEventListener('blur', () => {
const ws = document.getElementById('welcome-screen');
if (ws && !ws.classList.contains('hidden')) {
// Delay re-show so tapping within chatbox doesn't flash
setTimeout(() => {
if (document.activeElement !== msgInput) {
ws.classList.remove('kb-hidden');
}
}, 200);
}
});
}
// Smooth viewport resize when keyboard opens/closes
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
});
document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
}
}
// If the browser discarded and restored this tab, reload the current session
// so the user sees the server-saved partial response instead of a blank page
if (document.wasDiscarded) {
console.warn('[tab-recovery] Tab was discarded by browser — reloading session');
setTimeout(() => {
var _sid = sessionModule && sessionModule.getCurrentSessionId();
if (_sid) sessionModule.selectSession(_sid);
}, 500);
}
}
/**
* Regenerate response: truncate history to the user message before this AI message,
* then re-submit that user message.
*/
/**
* Edit a user message: show an input, truncate to before it, resubmit the edited text.
*/
export async function editUserMessage(userMsgElement) {
const box = document.getElementById('chat-history');
const allMsgs = Array.from(box.querySelectorAll('.msg'));
const msgIndex = allMsgs.indexOf(userMsgElement);
if (msgIndex < 0) return;
const bodyEl = userMsgElement.querySelector('.body');
let currentText = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
currentText = currentText.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
// Replace body with an editable textarea
const editor = document.createElement('textarea');
editor.className = 'edit-textarea';
editor.value = currentText;
editor.rows = Math.max(2, currentText.split('\n').length);
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex; gap:6px; margin-top:4px;';
const saveBtn = document.createElement('button');
saveBtn.className = 'edit-save-btn';
saveBtn.type = 'button';
saveBtn.title = 'Send edited message';
saveBtn.innerHTML = 'Send';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'edit-cancel-btn';
cancelBtn.type = 'button';
cancelBtn.title = 'Cancel editing';
cancelBtn.innerHTML = 'Cancel';
btnRow.appendChild(saveBtn);
btnRow.appendChild(cancelBtn);
const originalHTML = bodyEl.innerHTML;
bodyEl.innerHTML = '';
bodyEl.appendChild(editor);
bodyEl.appendChild(btnRow);
editor.focus();
cancelBtn.addEventListener('click', (e) => {
e.stopPropagation();
bodyEl.innerHTML = originalHTML;
});
saveBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const newText = editor.value.trim();
if (!newText) return;
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return;
const keepCount = msgIndex;
try {
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount })
});
// Remove DOM elements from msgIndex onward
for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
allMsgs[i].remove();
}
// Submit the edited text
const messageInput = uiModule.el('message');
messageInput.value = newText;
const submitBtn = document.querySelector('.send-btn');
if (submitBtn) submitBtn.click();
} catch (err) {
console.error('Edit failed:', err);
if (uiModule) uiModule.showError('Edit failed: ' + err.message);
bodyEl.innerHTML = originalHTML;
}
});
// Also submit on Enter (without shift)
editor.addEventListener('keydown', (e) => {
const isMobile = window.innerWidth <= 768
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
e.preventDefault();
saveBtn.click();
}
});
}
/**
* Resend a user message. By default this replaces from the selected user turn
* so failed attempts do not remain in model context or SFT traces. Callers can
* opt into append-only behavior with `{ append: true }`.
*/
export async function resendUserMessage(userMsgElement, opts = {}) {
const appendOnly = Boolean(opts && opts.append);
const replaceFromHere = !appendOnly || Boolean(opts && opts.replaceFromHere);
const box = document.getElementById('chat-history');
const allMsgs = Array.from(box.querySelectorAll('.msg'));
const msgIndex = allMsgs.indexOf(userMsgElement);
if (msgIndex < 0) return;
// Prefer dataset.raw (stripped original user text) over .body.textContent
// — the latter slurps the rendered "View image description" collapsible
// content too, which would then be sent back as the user's question and
// the AI would reply to that gibberish instead of the actual prompt.
const bodyEl = userMsgElement.querySelector('.body');
let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
// Collect file_ids attached to this user message so the resend re-carries
// the photos / docs (and the chat handler picks up the user-edited OCR
// text cached server-side under those file ids).
const _attachEls = userMsgElement.querySelectorAll('[data-file-id]');
let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
if (!_ids.length) {
const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
for (const _im of _imgs) {
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]);
}
}
// Rescue: legacy bubbles may have stored the filename as the message
// content (artifact of earlier broken resends). Don't re-send that as
// the user prompt if we still have the file attached. Loosen the regex
// to cover real-world camera/screenshot names with spaces, parens,
// multi-dots: "Screen Shot 2026-05-28 at 4.05.32 PM.png", "IMG (1).JPG".
if (text && _ids.length && /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(text)) {
text = '';
}
// Empty text + no attachments → tell the user instead of silently bailing.
// The common case is a regen during a pre-upload race where the bubble
// never had an `[data-file-id]` to scrape.
if (!text && !_ids.length) {
if (uiModule?.showError) uiModule.showError('Nothing to resend — message has no text and no attachments yet (try again after the upload finishes).');
return;
}
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return;
try {
if (replaceFromHere) {
// Resend/regenerate trims history to this point before resubmitting so
// the replacement request is the only copy the backend sees.
const keepCount = msgIndex;
const beforeMsgId = userMsgElement.dataset.dbId || '';
const truncateBody = beforeMsgId ? { before_msg_id: beforeMsgId } : { keep_count: keepCount };
const truncateRes = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(truncateBody)
});
if (!truncateRes.ok) throw new Error('Server error ' + truncateRes.status);
// Drop the selected user turn and every rendered trace after it,
// including agent-thread tool history that is not a `.msg` bubble.
let node = userMsgElement;
while (node) {
const prev = node;
node = node.nextElementSibling;
prev.remove();
}
}
_pendingRegenAttachments = _ids;
// Resubmit
const messageInput = uiModule.el('message');
messageInput.value = text;
const submitBtn = document.querySelector('.send-btn');
if (submitBtn) submitBtn.click();
} catch (err) {
console.error('Resend failed:', err);
if (uiModule) uiModule.showError('Resend failed: ' + err.message);
}
}
export async function regenerateFrom(aiMsgElement) {
const box = document.getElementById('chat-history');
const allMsgs = Array.from(box.querySelectorAll('.msg'));
const aiIndex = allMsgs.indexOf(aiMsgElement);
if (aiIndex < 0) return;
// Find the preceding user message
let userIndex = -1;
let userText = '';
let userMsgEl = null;
for (let i = aiIndex - 1; i >= 0; i--) {
if (allMsgs[i].classList.contains('msg-user')) {
userIndex = i;
userMsgEl = allMsgs[i];
// Prefer dataset.raw (set by addMessage with the stripped, original
// user text) over the rendered body's textContent — the latter
// pulls in the "View image description" collapsible content too,
// duplicating the OCR text on regen.
const bodyEl = userMsgEl.querySelector('.body');
userText = (userMsgEl.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
userText = userText.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
break;
}
}
if (userIndex < 0) {
if (uiModule) uiModule.showError('Could not find the user message to regenerate');
return;
}
// Collect any file_ids attached to the original user message so the
// regenerated send re-uses them. Without this the AI is regenerated on
// text alone — photos (and the user-edited OCR text cached server-side
// under that file_id) would be silently dropped.
const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
// Fallback for bubbles rendered before the data-file-id stamp landed:
// sniff the file id straight out of any `.attach-image-preview img`
// src URLs (matches /api/upload/). Otherwise an older bubble would
// regen with zero attachments and the photo would be lost from the
// resulting message even though the file still exists on disk.
if (!_regenIds.length && userMsgEl) {
const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img');
for (const _im of _imgs) {
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]);
}
}
_pendingRegenAttachments = _regenIds;
// Rescue: earlier-version regens (before the dataset.raw fix) stored the
// photo's filename as the user-message content. On a follow-up regen,
// that filename would be sent back as the literal user prompt, so the
// AI thinks the question is "blue_night_preview.jpg" and replies "that's
// an image file". If userText is just a bare image filename and we have
// attachments, drop it so the OCR text (or the image bytes for vision
// models) is what the model actually sees.
if (userText && _pendingRegenAttachments.length &&
/^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
userText = '';
}
// A photo-only message has empty user text — regen must still proceed,
// because the attachments themselves are the message. Bail only if there
// is no text AND no attachments to send.
if (!userText && !_pendingRegenAttachments.length) {
if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
return;
}
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return;
// Save current response as a variant
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
let variants = [];
try { variants = JSON.parse(aiMsgElement.dataset.variants || '[]'); } catch(_) {}
if (variants.length === 0) {
// First regen — save the original as variant 0
variants.push({ raw: oldRaw, html: oldHtml, label: 'original' });
}
const keepCount = userIndex;
try {
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount })
});
// Keep the original user bubble, but remove every rendered trace after
// it, including agent-thread tool history between the user and AI bubble.
let node = userMsgEl.nextElementSibling;
while (node) {
const prev = node;
node = node.nextElementSibling;
prev.remove();
}
// The old AI message was removed from DOM — it will be replaced by the new streaming response
// But first, stash the variants data so we can transfer it to the new element
_pendingVariants = variants;
_pendingVariantLabel = 'regen';
_hideUserBubble = true;
const messageInput = uiModule.el('message');
messageInput.value = userText;
const submitBtn = document.querySelector('.send-btn');
if (submitBtn) submitBtn.click();
} catch (err) {
console.error('Regenerate failed:', err);
if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
}
}
// Pending variants from a regeneration — transferred to new streaming element
let _pendingVariants = null;
let _pendingVariantLabel = null;
// File-ids carried over from the original user message during a regen, so
// photos / OCR overrides survive into the new send. Consumed once.
let _pendingRegenAttachments = null;
/**
* Called after streaming completes to attach variant navigation if this was a regen.
*/
function _attachVariantNav(msgElement) {
if (!_pendingVariants) return;
const variants = _pendingVariants;
_pendingVariants = null;
// Add the new response as the latest variant
const newRaw = msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || '';
const newHtml = msgElement.querySelector('.body')?.innerHTML || '';
const varLabel = _pendingVariantLabel || 'regen';
_pendingVariantLabel = null;
variants.push({ raw: newRaw, html: newHtml, label: varLabel });
msgElement.dataset.variants = JSON.stringify(variants);
msgElement.dataset.variantIndex = String(variants.length - 1);
_renderVariantNav(msgElement, variants, variants.length - 1);
// Persist variants to server
const sid = sessionModule.getCurrentSessionId();
if (sid) {
fetch(`${API_BASE}/api/session/${sid}/update-last-meta`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: { variants: variants, variantIndex: variants.length - 1 } })
}).catch(e => console.warn('update-last-meta (variants) failed:', e));
}
}
const _VARIANT_ICONS = { regen: '\u21BB', shorter: '\u2702', simpler: '?', original: '\u25CB' };
function _variantTagText(label) {
return _VARIANT_ICONS[label] || _VARIANT_ICONS['original'];
}
function _renderVariantNav(msgElement, variants, currentIdx) {
// Remove existing nav if any
const old = msgElement.querySelector('.variant-nav');
if (old) old.remove();
if (variants.length < 2) return;
const nav = document.createElement('span');
nav.className = 'variant-nav';
nav.addEventListener('click', (e) => e.stopPropagation());
// Label showing what this variant is
// Divider
const divider = document.createElement('span');
divider.className = 'variant-divider';
divider.textContent = '|';
nav.appendChild(divider);
// Label
const curVariant = variants[currentIdx];
const tagLabel = document.createElement('span');
tagLabel.className = 'variant-tag' + (curVariant?.label === 'shorter' ? ' variant-tag-scissors' : '');
tagLabel.textContent = _variantTagText(curVariant?.label);
nav.appendChild(tagLabel);
// < button
const prevBtn = document.createElement('button');
prevBtn.className = 'variant-btn';
prevBtn.textContent = '<';
prevBtn.disabled = currentIdx === 0;
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx - 1); });
nav.appendChild(prevBtn);
// Clickable number for current index (click left number = go left, right = go right)
const numLeft = document.createElement('button');
numLeft.className = 'variant-num';
numLeft.textContent = String(currentIdx + 1);
numLeft.disabled = currentIdx === 0;
numLeft.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx - 1); });
nav.appendChild(numLeft);
const slash = document.createElement('span');
slash.className = 'variant-slash';
slash.textContent = '/';
nav.appendChild(slash);
const numRight = document.createElement('button');
numRight.className = 'variant-num';
numRight.textContent = String(variants.length);
numRight.disabled = currentIdx === variants.length - 1;
numRight.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx + 1); });
nav.appendChild(numRight);
// > button
const nextBtn = document.createElement('button');
nextBtn.className = 'variant-btn';
nextBtn.textContent = '>';
nextBtn.disabled = currentIdx === variants.length - 1;
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx + 1); });
nav.appendChild(nextBtn);
// Insert into the .role header
const roleEl = msgElement.querySelector('.role');
if (roleEl) {
roleEl.appendChild(nav);
} else {
msgElement.appendChild(nav);
}
}
function _switchVariant(msgElement, variants, newIdx) {
if (newIdx < 0 || newIdx >= variants.length) return;
const v = variants[newIdx];
const body = msgElement.querySelector('.body');
if (body) body.innerHTML = v.html;
msgElement.dataset.raw = v.raw;
msgElement.dataset.variantIndex = String(newIdx);
if (window.hljs) {
msgElement.querySelectorAll('pre code').forEach(block => window.hljs.highlightElement(block));
}
_renderVariantNav(msgElement, variants, newIdx);
// Persist selected variant to server
const sid = sessionModule.getCurrentSessionId();
if (sid) {
fetch(`${API_BASE}/api/session/${sid}/update-last-meta`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: { variantIndex: newIdx } })
}).catch(e => console.warn('update-last-meta (variantIndex) failed:', e));
}
}
export async function forkFrom(aiMsgElement) {
const box = document.getElementById('chat-history');
const allMsgs = Array.from(box.querySelectorAll('.msg'));
const aiIndex = allMsgs.indexOf(aiMsgElement);
if (aiIndex < 0) return;
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return;
const keepCount = aiIndex + 1;
try {
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount }),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
await sessionModule.loadSessions();
await sessionModule.selectSession(data.id);
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
} catch (err) {
console.error('Fork failed:', err);
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
}
}
/**
* Check for pending/completed research after page refresh or session switch.
* If research is still running, show a spinner and poll until done.
* If research is done, fetch result and render it.
*/
export async function checkPendingResearch(sessionId) {
if (!sessionId) return;
try {
const res = await fetch(`${API_BASE}/api/research/status/${sessionId}`);
if (!res.ok) {
if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId);
return; // 404 = no research for this session
}
const data = await res.json();
if (data.status === 'done') {
// Fetch and render the completed result
_notifyResearchComplete(sessionId, data.query || '');
if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId);
const resultRes = await fetch(`${API_BASE}/api/research/result/${sessionId}`, { method: 'POST' });
if (resultRes.ok) {
const resultData = await resultRes.json();
if (resultData.result) {
// Skip if history already has a research message for this session
if (document.querySelector(`#chat-history .msg-ai[data-research-session="${sessionId}"]`)) return;
var srcBox = '';
if (resultData.sources && resultData.sources.length > 0) {
srcBox = _buildSourcesBox(resultData.sources, 'research');
}
var findingsBox = chatRenderer.buildFindingsBox(resultData.raw_findings);
var cleanResult = resultData.result;
// Build DOM directly to avoid double-processing through addMessage
chatRenderer.hideWelcomeScreen();
var _box = document.getElementById('chat-history');
if (_box) {
var _wrap = document.createElement('div');
_wrap.className = 'msg msg-ai';
_wrap.dataset.researchSession = sessionId;
var _role = document.createElement('div');
_role.className = 'role';
var _meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; });
_role.textContent = _shortModel(_meta?.model);
_applyModelColor(_role, _meta?.model);
_role.appendChild(chatRenderer.roleTimestamp());
var _body = document.createElement('div');
_body.className = 'body';
_body.innerHTML = srcBox + markdownModule.processWithThinking(
markdownModule.squashOutsideCode(cleanResult)
) + findingsBox;
_wrap.dataset.raw = cleanResult;
_wrap.appendChild(_role);
_wrap.appendChild(_body);
_wrap.appendChild(chatRenderer.createMsgFooter(_wrap));
_appendViewReportLink(_wrap, sessionId);
_box.appendChild(_wrap);
if (window.hljs) _wrap.querySelectorAll('pre code').forEach(function(b) { window.hljs.highlightElement(b); });
uiModule.scrollHistory();
}
}
}
return;
}
if (data.status !== 'running') return;
// Don't show reconnect UI if we've already switched away
if (sessionModule.getCurrentSessionId() !== sessionId) return;
// Research is still running — show reconnect UI with spinner
const box = document.getElementById('chat-history');
if (!box) return;
const holder = document.createElement('div');
holder.className = 'msg msg-ai research-reconnect';
holder.dataset.researchSession = sessionId;
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const agentMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
const agentModelLabel = _shortModel(agentMeta?.model);
holder.innerHTML = `
${uiModule.esc(agentModelLabel)} ${roleTs}
`;
_applyModelColor(holder.querySelector('.role'), agentMeta?.model);
box.appendChild(holder);
const bodyDiv = holder.querySelector('.body');
const spinner = spinnerModule.create('Reconnecting to research...', 'right');
bodyDiv.appendChild(spinner.createElement());
spinner.start();
// Update spinner with current progress if available
function updateSpinnerFromProgress(progress) {
if (!progress || !progress.phase) return;
const rp = progress;
if (rp.phase === 'probing') {
spinner.updateMessage(`Verifying model: ${rp.model || '?'}`);
} else if (rp.phase === 'planning') {
spinner.updateMessage('Analyzing question & planning research strategy');
} else if (rp.phase === 'searching') {
const q = rp.queries ? `${rp.queries} queries` : '';
const s = rp.total_sources ? ` · ${rp.total_sources} sources` : '';
spinner.updateMessage(`Round ${rp.round || '?'}: Searching${q ? ' (' + q + ')' : ''}${s}`);
} else if (rp.phase === 'reading') {
spinner.updateMessage(rp.title ? `Reading: ${rp.title}` : `Round ${rp.round || '?'}: Reading ${rp.new_sources || ''} pages · ${rp.total_sources || 0} sources total`);
} else if (rp.phase === 'analyzing') {
spinner.updateMessage(`Round ${rp.round || '?'}: Analyzing ${rp.total_findings || 0} findings`);
} else if (rp.phase === 'writing') {
spinner.updateMessage(`Writing report · ${rp.total_sources || 0} sources`);
}
}
updateSpinnerFromProgress(data.progress);
_researchingStreamIds.add(sessionId);
if (sessionModule && sessionModule.markResearching) sessionModule.markResearching(sessionId);
// Restore research timer from started_at
if (data.started_at && spinner && spinner.element) {
_researchStartTime = data.started_at * 1000;
_researchAvgDuration = data.avg_duration || null;
_researchTimerEl = document.createElement('div');
_researchTimerEl.className = 'research-timer';
_researchTimerEl.style.cssText = 'font-size:0.8em; opacity:0.6; margin-top:4px; font-family:monospace;';
spinner.element.parentNode.insertBefore(_researchTimerEl, spinner.element.nextSibling);
_researchTimerInterval = setInterval(() => {
if (!_researchTimerEl) return;
var elapsed = Math.floor((Date.now() - _researchStartTime) / 1000);
var mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
var ss = String(elapsed % 60).padStart(2, '0');
var txt = mm + ':' + ss;
if (_researchAvgDuration) {
var avgM = String(Math.floor(_researchAvgDuration / 60)).padStart(2, '0');
var avgS = String(Math.round(_researchAvgDuration % 60)).padStart(2, '0');
txt += ' / avg ' + avgM + ':' + avgS;
}
_researchTimerEl.textContent = txt;
}, 1000);
// Reconnect synapse — seed it with whatever progress is already known
try {
_researchSynapse = createResearchSynapse(spinner.element.parentNode, {
query: data.query || '',
startedAt: _researchStartTime,
});
if (_researchSynapse.element && _researchTimerEl) {
spinner.element.parentNode.insertBefore(_researchSynapse.element, _researchTimerEl);
}
if (data.progress) {
_researchSynapse.setPhase(data.progress.phase, data.progress);
if (typeof data.progress.round === 'number') _researchSynapse.setRound(data.progress.round);
if (typeof data.progress.total_sources === 'number') _researchSynapse.setSourceCount(data.progress.total_sources);
}
} catch (e) { console.warn('synapse reconnect failed', e); }
}
// Poll for completion
const pollInterval = setInterval(async () => {
// Stop polling if user switched to a different session
if (sessionModule.getCurrentSessionId() !== sessionId) {
clearInterval(pollInterval);
spinner.destroy();
_clearResearchTimer();
if (holder.parentNode) holder.remove();
_researchingStreamIds.delete(sessionId);
if (_researchingStreamIds.size === 0) {
var _rToggleP = document.getElementById('research-toggle-btn');
if (_rToggleP) _rToggleP.classList.remove('research-running');
}
return;
}
try {
const pollRes = await fetch(`${API_BASE}/api/research/status/${sessionId}`);
if (!pollRes.ok) {
clearInterval(pollInterval);
spinner.destroy();
_clearResearchTimer();
_researchingStreamIds.delete(sessionId);
if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId);
return;
}
const pollData = await pollRes.json();
updateSpinnerFromProgress(pollData.progress);
if (_researchSynapse && pollData.progress) {
_researchSynapse.setPhase(pollData.progress.phase, pollData.progress);
if (typeof pollData.progress.round === 'number') _researchSynapse.setRound(pollData.progress.round);
if (typeof pollData.progress.total_sources === 'number') _researchSynapse.setSourceCount(pollData.progress.total_sources);
}
if (pollData.status !== 'running') {
clearInterval(pollInterval);
spinner.destroy();
_clearResearchTimer();
_researchingStreamIds.delete(sessionId);
if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId);
if (pollData.status === 'done') {
_notifyResearchComplete(sessionId, data.query || '');
const rRes = await fetch(`${API_BASE}/api/research/result/${sessionId}`, { method: 'POST' });
if (rRes.ok) {
const rData = await rRes.json();
if (rData.result) {
var srcHtml = '';
if (rData.sources && rData.sources.length > 0) {
srcHtml = _buildSourcesBox(rData.sources, 'research');
}
var findingsHtml = chatRenderer.buildFindingsBox(rData.raw_findings);
bodyDiv.innerHTML = srcHtml + markdownModule.processWithThinking(
markdownModule.squashOutsideCode(rData.result)
) + findingsHtml;
holder.dataset.raw = rData.result;
_appendViewReportLink(holder, sessionId);
if (window.hljs) {
holder.querySelectorAll('pre code').forEach(b => window.hljs.highlightElement(b));
}
}
}
} else {
bodyDiv.innerHTML = '[Research ' + pollData.status + ']';
}
}
} catch (e) {
console.error('Research poll error:', e);
}
}, 2000);
} catch (e) {
// No research pending, that's fine
}
}
/** Set a display override for the next user message bubble */
export function setDisplayOverride(text) {
_displayOverride = text;
}
/** Hide the user bubble for the next submit (e.g. continue after stop) */
export function setHideUserBubble() {
_hideUserBubble = true;
}
/** Set the AI element to merge with the next streamed response (continue after stop) */
export function setPendingContinue(el) {
_pendingContinue = el;
}
/**
* Delete an AI message and its preceding user message from the conversation.
*/
export async function deleteMessage(msgElement) {
if (uiModule && uiModule.styledConfirm) {
const ok = await uiModule.styledConfirm('Delete this message?', {
confirmText: 'Delete',
cancelText: 'Cancel',
danger: true,
});
if (!ok) return;
}
const box = document.getElementById('chat-history');
const allMsgs = Array.from(box.querySelectorAll('.msg'));
const clickedIndex = allMsgs.indexOf(msgElement);
if (clickedIndex < 0) return;
// No early-out on a missing session: an output shown before any model was
// selected (issue #1428) has no session/persisted rows, but its "x" must
// still remove it. We only need the session id for the server-side delete
// below; without one we fall back to removing the DOM.
const sessionId = sessionModule.getCurrentSessionId();
const clickedIsUser = msgElement.classList.contains('msg-user');
// Find the user+AI pair
let userIndex = -1;
let aiIndex = -1;
if (clickedIsUser) {
userIndex = clickedIndex;
// Find the following AI message
for (let i = clickedIndex + 1; i < allMsgs.length; i++) {
if (allMsgs[i].classList.contains('msg-ai') && !allMsgs[i].classList.contains('msg-continuation')) {
aiIndex = i;
break;
}
if (allMsgs[i].classList.contains('msg-user')) break; // next user msg, no AI response
}
} else {
// If clicked on a continuation, walk back to the main AI message
let mainAiIndex = clickedIndex;
if (allMsgs[mainAiIndex].classList.contains('msg-continuation')) {
for (let i = mainAiIndex - 1; i >= 0; i--) {
if (allMsgs[i].classList.contains('msg-ai') && !allMsgs[i].classList.contains('msg-continuation')) {
mainAiIndex = i;
break;
}
}
}
aiIndex = mainAiIndex;
// Find the preceding user message
for (let i = aiIndex - 1; i >= 0; i--) {
if (allMsgs[i].classList.contains('msg-user')) {
userIndex = i;
break;
}
}
}
// Collect DB message IDs and DOM elements to remove
const msgIds = [];
const domToRemove = [];
// Add the user message if found
if (userIndex >= 0) {
domToRemove.push(allMsgs[userIndex]);
const uid = allMsgs[userIndex].dataset.dbId;
if (uid) msgIds.push(uid);
}
// Add the AI message if found
if (aiIndex >= 0) {
domToRemove.push(allMsgs[aiIndex]);
const aid = allMsgs[aiIndex].dataset.dbId;
if (aid) msgIds.push(aid);
const aiEl = allMsgs[aiIndex];
// Also remove agent-thread elements BETWEEN user and AI
if (userIndex >= 0) {
let between = allMsgs[userIndex].nextElementSibling;
while (between && between !== aiEl) {
domToRemove.push(between);
between = between.nextElementSibling;
}
}
// Walk forward from the AI element to remove continuations and tool bubbles
let sibling = aiEl.nextElementSibling;
while (sibling) {
if (sibling.classList.contains('msg-user') ||
(sibling.classList.contains('msg-ai') && !sibling.classList.contains('msg-continuation'))) {
break;
}
domToRemove.push(sibling);
sibling = sibling.nextElementSibling;
}
}
if (!msgIds.length || !sessionId) {
// No persisted rows to delete (no DB IDs, or no session at all — e.g. an
// error output shown before a model was selected, #1428). Just remove the
// DOM so the "x" works regardless.
domToRemove.forEach(el => el.remove());
if (uiModule) uiModule.showToast('Message deleted');
return;
}
try {
const res = await fetch(`${API_BASE}/api/session/${sessionId}/delete-messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ msg_ids: msgIds })
});
if (!res.ok) throw new Error('Server error ' + res.status);
domToRemove.forEach(el => el.remove());
if (uiModule) uiModule.showToast('Message deleted');
} catch (err) {
console.error('Delete failed:', err);
if (uiModule) uiModule.showError('Delete failed: ' + err.message);
}
}
/**
* Edit an AI message inline. Makes the body contentEditable, saves to DB on confirm.
*/
export async function editAIMessage(msgElement) {
const body = msgElement.querySelector('.body');
if (!body) return;
const isEditing = body.contentEditable === 'true' || body.contentEditable === 'plaintext-only';
if (isEditing) return; // already editing
const originalRaw = msgElement.dataset.raw || body.textContent || '';
// Create editable textarea overlay
const textarea = document.createElement('textarea');
textarea.className = 'msg-edit-textarea';
textarea.value = originalRaw;
textarea.style.width = '100%';
textarea.style.minHeight = Math.max(100, body.offsetHeight) + 'px';
body.style.display = 'none';
body.parentNode.insertBefore(textarea, body.nextSibling);
textarea.focus();
// Add save/cancel bar
const bar = document.createElement('div');
bar.className = 'msg-edit-bar';
const saveBtn = document.createElement('button');
saveBtn.className = 'msg-edit-save';
saveBtn.textContent = 'Save';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'msg-edit-cancel';
cancelBtn.textContent = 'Cancel';
bar.appendChild(saveBtn);
bar.appendChild(cancelBtn);
textarea.parentNode.insertBefore(bar, textarea.nextSibling);
function cleanup() {
textarea.remove();
bar.remove();
body.style.display = '';
}
cancelBtn.addEventListener('click', (e) => {
e.stopPropagation();
cleanup();
});
saveBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const newContent = textarea.value;
if (newContent === originalRaw) { cleanup(); return; }
const msgId = msgElement.dataset.dbId;
if (!msgId) { if (uiModule) uiModule.showError('Cannot edit: message ID not found'); cleanup(); return; }
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) { cleanup(); return; }
try {
const res = await fetch(`${API_BASE}/api/session/${sessionId}/edit-message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ msg_id: msgId, content: newContent }),
});
if (!res.ok) throw new Error('Server error ' + res.status);
// Re-render body with markdown
body.innerHTML = markdownModule.processWithThinking(markdownModule.squashOutsideCode(newContent));
msgElement.dataset.raw = newContent;
// Add edited indicator if not already present
if (!msgElement.querySelector('.edited-indicator')) {
const indicator = document.createElement('div');
indicator.className = 'edited-indicator';
indicator.textContent = '[Message edited]';
body.parentNode.insertBefore(indicator, body.nextSibling);
}
cleanup();
if (uiModule) uiModule.showToast('Message edited');
} catch (err) {
console.error('Edit failed:', err);
if (uiModule) uiModule.showError('Edit failed: ' + err.message);
}
});
}
/**
* Rewrite the AI's last response with a specific instruction.
* Uses the lightweight /api/rewrite endpoint — no tools, no agent loop.
* Just rewrites the text of the last AI bubble.
*/
export async function rewriteWith(aiMsgElement, instruction) {
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return;
// Get the original text from the AI bubble
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
if (!oldRaw.trim()) {
if (uiModule) uiModule.showError('No text to rewrite');
return;
}
// Save current response as a variant
let variants = [];
try { variants = JSON.parse(aiMsgElement.dataset.variants || '[]'); } catch(_) {}
if (variants.length === 0) {
variants.push({ raw: oldRaw, html: oldHtml, label: 'original' });
}
// Determine label from instruction
let varLabel = 'rewrite';
if (instruction.includes('shorter')) varLabel = 'shorter';
else if (instruction.includes('simpler')) varLabel = 'simpler';
// Clear the bubble and show a whirlpool spinner while we wait for the
// rewrite (replaces the old "Rewriting..." text).
const bodyEl = aiMsgElement.querySelector('.body');
let _rwSpin = null;
if (bodyEl) {
bodyEl.innerHTML = '';
_rwSpin = spinnerModule.createWhirlpool(18);
_rwSpin.element.style.margin = '4px 0';
bodyEl.appendChild(_rwSpin.element);
}
// Stop + detach the spinner (called once real content starts rendering, and
// on the failure path so it never spins forever).
const _killRwSpin = () => { if (_rwSpin) { try { _rwSpin.destroy(); } catch (_) {} _rwSpin = null; } };
try {
const res = await fetch(`${API_BASE}/api/rewrite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
original_text: oldRaw,
instruction: instruction,
}),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let newText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (payload === '[DONE]') continue;
try {
const data = JSON.parse(payload);
// The endpoint streams `event: error\ndata: {error,status}` on
// failure — surface it instead of silently hanging on "Rewriting…".
if (data.error) {
throw new Error(data.error || ('HTTP ' + (data.status || 500)));
}
// Reasoning tokens (vLLM --reasoning-parser: Qwen3 / DeepSeek-R1)
// arrive as separate {delta, thinking:true} chunks. They are NOT
// the rewrite — fold them away so they don't pollute the result.
if (data.thinking) continue;
if (data.delta) {
newText += data.delta;
_killRwSpin();
if (bodyEl) {
bodyEl.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(newText)
);
}
}
} catch (e) {
if (e instanceof Error && e.message) throw e; // re-throw real errors
/* ignore JSON parse noise */
}
}
}
// Strip any thinking markup from the answer. A reasoning model may emit
// an inline … block, a bare
(no opener), or — when
// its reasoning came via reasoning_content — a stray leading that
// never closes (so it would otherwise hide the whole answer). Peel all of
// those off so what's left is just the rewritten text.
const _stripThink = (t) => {
t = markdownModule.normalizeThinkingMarkup(t || '');
t = t.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>[\s\S]*?<\/(?:think(?:ing)?|thought)>/gi, ''); // complete blocks
if (/<\/(?:think(?:ing)?|thought)>/i.test(t)) t = t.replace(/^[\s\S]*?<\/(?:think(?:ing)?|thought)>/i, ''); // reasoning w/o opener
return t.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '').trim(); // any orphan tag
};
newText = _stripThink(newText);
// Nothing left after stripping (or an empty stream) → real failure, not a
// blank bubble.
if (!newText.trim()) {
throw new Error('model returned no rewritten text');
}
// Update the element's raw text
if (newText) {
aiMsgElement.dataset.raw = newText;
// Final render with proper markdown
if (bodyEl) {
bodyEl.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(newText)
);
}
// Save the new response as a variant
variants.push({ raw: newText, html: bodyEl ? bodyEl.innerHTML : '', label: varLabel });
aiMsgElement.dataset.variants = JSON.stringify(variants);
aiMsgElement.dataset.variantIndex = String(variants.length - 1);
// Persist variant metadata to server
try {
await fetch(`${API_BASE}/api/session/${sessionId}/update-last-meta`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: { variants: variants, variantIndex: variants.length - 1 } }),
});
} catch (_) {}
// Re-render variant navigation
_renderVariantNav(aiMsgElement, variants, variants.length - 1);
}
if (uiModule) uiModule.scrollHistory();
} catch (err) {
console.error('Rewrite failed:', err);
_killRwSpin();
// Restore original content on failure
if (bodyEl) bodyEl.innerHTML = oldHtml;
if (uiModule) uiModule.showError('Rewrite failed: ' + err.message);
}
}
/**
* Continue the AI's response from where it left off.
*/
export async function continueFrom(aiMsgElement) {
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return;
const messageInput = uiModule.el('message');
if (messageInput) {
messageInput.value = 'Continue from where you left off.';
const submitBtn = document.querySelector('.send-btn');
if (submitBtn) submitBtn.click();
}
}
// Open a chat attachment in the right place: images → Gallery editor; PDFs &
// text/code/markdown → Documents viewer; anything else → raw file. A given
// upload's imported document is reused (cached by upload id) so clicking it
// again re-opens the same doc instead of making duplicates.
const _attachDocCache = new Map(); // upload id -> doc id
function _attachLang(name) {
const m = (name || '').toLowerCase().match(/\.([a-z0-9]+)$/);
const ext = m ? m[1] : '';
const map = { md:'markdown', markdown:'markdown', js:'javascript', ts:'typescript',
jsx:'javascript', tsx:'typescript', py:'python', rb:'ruby', go:'go', rs:'rust',
java:'java', c:'c', cpp:'cpp', h:'c', hpp:'cpp', cs:'csharp', php:'php', html:'html',
htm:'html', css:'css', scss:'scss', json:'json', yaml:'yaml', yml:'yaml', sh:'bash',
bash:'bash', sql:'sql', csv:'csv', xml:'xml' };
return map[ext] || '';
}
async function openAttachment(att, isImage) {
if (!att || !att.id) return;
const id = att.id, name = att.name || '', mime = att.mime || '';
const url = `${API_BASE}/api/upload/${id}`;
// Images → Gallery editor.
if (isImage) {
try {
const gx = await loadPanel('editor');
if (gx.openEditor) { gx.openEditor(url, id, null, name); return; }
} catch (e) { console.warn('gallery open failed', e); }
window.open(url, '_blank');
return;
}
const isPdf = mime === 'application/pdf' || /\.pdf$/i.test(name);
const TEXT_EXT = /\.(txt|md|markdown|js|ts|jsx|tsx|py|rb|go|rs|java|c|cpp|h|hpp|cs|php|html?|css|scss|sass|less|json|ya?ml|toml|ini|conf|env|sh|bash|sql|csv|tsv|xml|log|vue|svelte)$/i;
const isTextDoc = TEXT_EXT.test(name) || /^text\//.test(mime);
if (!isPdf && !isTextDoc) { window.open(url, '_blank'); return; } // binary/unknown → raw
// Reuse the doc we already imported for this upload, if it still loads.
const cached = _attachDocCache.get(id);
if (cached) {
try {
documentModule.openPanel && documentModule.openPanel();
await documentModule.loadDocument(cached);
return;
} catch (_) { _attachDocCache.delete(id); }
}
// Need a session to attach the doc to (bare-session fallback, same as compose).
let sid = '';
try { sid = sessionModule.getCurrentSessionId() || ''; } catch (_) {}
if (!sid) {
try {
const _fd = new FormData();
_fd.append('name', name || 'Attachment');
_fd.append('skip_validation', 'true');
const r = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' });
if (r.ok) { const d = await r.json(); if (d && d.id) { sid = d.id; if (sessionModule.loadSessions) await sessionModule.loadSessions(); } }
} catch (_) {}
}
try {
let doc;
if (isPdf) {
// import-pdf wants a fresh file upload — re-fetch the stored blob and post it.
const blob = await (await fetch(url)).blob();
const fd = new FormData();
fd.append('file', blob, name || 'document.pdf');
if (sid) fd.append('session_id', sid);
const res = await fetch(`${API_BASE}/api/documents/import-pdf`, { method: 'POST', body: fd, credentials: 'same-origin' });
if (!res.ok) throw new Error('import-pdf ' + res.status);
doc = await res.json();
} else {
const text = await (await fetch(url)).text();
const res = await fetch(`${API_BASE}/api/document`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sid || null, title: name.replace(/\.[^.]+$/, '') || 'Document', content: text, language: _attachLang(name) }),
});
if (!res.ok) throw new Error('document ' + res.status);
doc = await res.json();
}
if (doc && doc.id) {
_attachDocCache.set(id, doc.id);
documentModule.openPanel && documentModule.openPanel();
if (documentModule.injectFreshDoc) documentModule.injectFreshDoc(doc);
else await documentModule.loadDocument(doc.id);
}
} catch (e) {
console.error('open attachment as document failed', e);
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Could not open attachment')).catch(() => {});
window.open(url, '_blank'); // fallback so the file is still reachable
}
}
// Public API
const chatModule = {
init,
initListeners,
openAttachment,
addMessage: chatRenderer.addMessage,
displayMetrics: chatRenderer.displayMetrics,
handleChatSubmit,
abortCurrentRequest,
detachCurrentStream,
checkBackgroundStream,
resumeStream,
hideWelcomeScreen: chatRenderer.hideWelcomeScreen,
showWelcomeScreen: chatRenderer.showWelcomeScreen,
checkPendingResearch,
getImageCost: chatRenderer.getImageCost,
setDisplayOverride,
setHideUserBubble,
setPendingContinue,
regenerateFrom,
forkFrom,
editUserMessage,
editAIMessage,
resendUserMessage,
deleteMessage,
rewriteWith,
continueFrom,
_appendViewReportLink,
hasActiveStream,
openContextSettings: async () => {
const pill = document.getElementById('chat-context-pill');
if (pill && !pill.hidden) {
pill.click();
return true;
}
const sm = _liveSessionModule();
if (sm?.hasPendingChat?.() && sm?.materializePendingSession) {
try {
const materialized = await sm.materializePendingSession();
if (materialized) {
await refreshChatContextHeader('open-context-settings');
if (pill && !pill.hidden) {
pill.click();
return true;
}
}
} catch (_) {}
}
return false;
},
};
// Single delegated handler for tool-call fold/expand. One listener on
// document.body covers every .agent-thread-node — running, completed,
// streaming, history-rendered, compare-mode, all of them. Re-attaching
// per-node listeners on every innerHTML rewrite was the source of the
// "needs many clicks" bug.
if (!window.__odysseus_thread_click_bound) {
document.body.addEventListener('click', (e) => {
const browserFrame = e.target.closest('.private-browser-preview-frame');
if (browserFrame && browserFrame.querySelector('.private-browser-preview-img[src]')) {
const preview = browserFrame.closest('.private-browser-preview');
const url = preview && preview.dataset ? String(preview.dataset.browserUrl || '') : '';
if (url) {
e.preventDefault();
e.stopPropagation();
window.open(url, '_blank', 'noopener,noreferrer');
return;
}
}
const browserFold = e.target.closest('.private-browser-preview-fold');
const browserHeader = e.target.closest('.private-browser-preview-header');
if (browserFold || browserHeader) {
e.preventDefault();
e.stopPropagation();
const preview = (browserFold || browserHeader).closest('.private-browser-preview');
if (preview) {
preview.classList.toggle('folded');
const foldButton = preview.querySelector('.private-browser-preview-fold');
if (foldButton) {
foldButton.textContent = preview.classList.contains('folded') ? '+' : '×';
foldButton.title = preview.classList.contains('folded') ? 'Unfold browser preview' : 'Fold browser preview';
foldButton.setAttribute('aria-label', foldButton.title);
}
}
return;
}
const header = e.target.closest('.agent-thread-header');
if (!header) return;
if (e.target.closest('.agent-thread-header-link')) return;
const node = header.closest('.agent-thread-node');
if (!node) return;
if (node.classList.contains('browser-preview-node')) return;
const opened = node.classList.toggle('open');
if (opened) {
// Expanding the final tool trace can push a pending ask_user card below
// the viewport. Keep that immediately-adjacent prompt visible.
const thread = node.closest('.agent-thread');
const pendingCard = thread?.nextElementSibling;
if (pendingCard?.classList.contains('ask-user-card')) {
requestAnimationFrame(() => pendingCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }));
}
}
});
window.__odysseus_thread_click_bound = true;
}
export default chatModule;
window.chatModule = chatModule;