PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/chat.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:38 UTC 248,944 bytes, 5,050 lines download raw bytes

1// static/js/chat.js
2
3/**
4 * Main chat functionality - message handling and streaming
5 */
6// ES6 module — IIFE removed
7
8import Storage from './storage.js';
9import uiModule from './ui.js';
10import sessionModule from './sessions.js';
11import chatRenderer from './chatRenderer.js';
12import chatStream from './chatStream.js';
13import { addAITTSButton } from './tts-ai.js';
14import markdownModule from './markdown.js';
15import { svgifyEmoji } from './markdown.js';
16import planWindowModule from './planWindow.js';
17import spinnerModule from './spinner.js';
18import presetsModule from './presets.js';
19import fileHandlerModule from './fileHandler.js';
20import searchModule from './search.js';
21import documentModule from './document.js';
22import * as emailInbox from './emailInbox.js';
23import codeRunnerModule from './codeRunner.js';
24import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js';
25import createResearchSynapse from './researchSynapse.js';
26import { createStreamRenderer } from './streamingRenderer.js';
27  const RESEARCH_TIMEOUT_MS = 360000;
28  const DEFAULT_TIMEOUT_MS = 120000;
29  const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
30
31  let API_BASE = '';
32  let currentAbort = null;
33  let isStreaming = false;
34  // Continuous stall watchdog: while streaming, if the SSE stream produces
35  // NOTHING for STALL_THRESHOLD_MS (no deltas, no tool heartbeat — tools beat
36  // every 2s, so a full minute of silence means it's genuinely stuck or the
37  // model quietly stopped), surface a non-destructive "still working?" prompt
38  // instead of silently hanging. Replaces relying only on the tab-refocus
39  // recovery (which fired only on visibilitychange and silently reloaded).
40  let _stallWatchdog = null;
41  let _stallBannerShown = false;
42  const STALL_THRESHOLD_MS = 60000;
43  let _sendInFlight = false;   // covers the window from click → streaming start
44  let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
45  let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
46  let _pendingContinue = null; // Stores the stopped AI element to merge with new response
47  // ── Auto-recovery: when a turn's stream silently dies (connection drop) or
48  // goes quiet while the connection is alive, re-engage the model with a
49  // completion handshake instead of leaving it hung. Capped so it can't loop.
50  let _autoNudges = 0;             // handshakes fired for the CURRENT user turn
51  let _autoContinuePending = false; // marks the next submit as an auto-continue (don't reset the counter)
52  const _AUTO_NUDGE_CAP = 3;
53
54  // shortModel and modelColor are now in chatRenderer.js
55  var _shortModel = chatRenderer.shortModel;
56  var _modelRouteLabel = chatRenderer.modelRouteLabel;
57  var _sameModelName = chatRenderer.sameModelName;
58  var _applyModelColor = chatRenderer.applyModelColor;
59  function _setRoleModelLabel(roleEl, requestedModel, actualModel, opts) {
60    if (!roleEl) return;
61    opts = opts || {};
62    const tsSpan = roleEl.querySelector('.role-timestamp');
63    const req = requestedModel || actualModel || '';
64    const actual = actualModel || requestedModel || '';
65    let label = _modelRouteLabel(req, actual);
66    if (opts.suffix) label += ' (' + opts.suffix + ')';
67    if (opts.characterName) label = opts.characterName;
68    roleEl.textContent = label + ' ';
69    _applyModelColor(roleEl, actual || req);
70    if (req && actual && !_sameModelName(req, actual)) {
71      roleEl.title = req + ' -> ' + actual + (opts.reason ? ': ' + opts.reason : '');
72    } else if (!opts.reason) {
73      roleEl.removeAttribute('title');
74    }
75    if (tsSpan) roleEl.appendChild(tsSpan);
76  }
77  // Per-session research tracking (supports concurrent research across sessions)
78  const _researchingStreamIds = new Set();
79  let _researchTimerEl = null, _researchTimerInterval = null;
80  let _researchStartTime = 0, _researchAvgDuration = null;
81  let _researchSynapse = null;
82  function _clearResearchTimer() {
83    if (_researchTimerInterval) { clearInterval(_researchTimerInterval); _researchTimerInterval = null; }
84    if (_researchTimerEl) { _researchTimerEl.remove(); _researchTimerEl = null; }
85    if (_researchSynapse) {
86      // Mark complete first so the user briefly sees the "done" state,
87      // then tear it down on next tick.
88      try { _researchSynapse.complete(); } catch {}
89      const s = _researchSynapse;
90      _researchSynapse = null;
91      setTimeout(() => { try { s.destroy(); } catch {} }, 800);
92    }
93    _researchStartTime = 0;
94    _researchAvgDuration = null;
95  }
96
97  /** Append a "Generate Visual Report" button — delegates to chatRenderer. */
98  function _appendViewReportLink(msgEl, sessionId) {
99    const body = msgEl.querySelector('.body');
100    if (body) chatRenderer.appendReportButton(body, sessionId);
101  }
102  let currentAccumulated = ''; // Track accumulated text across function scope
103  let currentHolder = null; // Track current message holder
104  let currentSpinner = null; // Track current spinner for stop cleanup
105
106  // Background streaming support
107  const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
108  const _resumingStreams = new Set();   // sessionId -> a resumeStream() reader is live (re-attach lock)
109  let _streamSessionId = null; // Session ID for the currently active reader loop
110  let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
111  let _webLockRelease = null;  // Function to release the Web Lock held during streaming
112  let _forcePlanOff = false;   // One-shot: suppress plan_mode for the next send (Approve & Run)
113
114  // ── Plan store: the latest proposed/approved checklist for the CURRENT chat ──
115  // Kept so (a) it can be sent back each turn and pinned in context (a long plan
116  // on a weak model survives history truncation), and (b) the plan window can be
117  // re-opened/docked at any time via the plan-button menu. Stored per session in
118  // localStorage so it survives a reload mid-execution.
119  function _setStoredPlan(text) {
120    const sid = sessionModule.getCurrentSessionId();
121    if (!sid || !text || !text.trim()) return;
122    Storage.setJSON(Storage.KEYS.PLAN, { sid, text });
123    // Live-refresh the plan window if it's open (shows progress as the agent
124    // restates the checklist with [x]).
125    try {
126      if (planWindowModule.isPlanWindowOpen && planWindowModule.isPlanWindowOpen()) {
127        planWindowModule.openPlanWindow(text, null);
128      }
129    } catch (_) {}
130  }
131  function _getStoredPlan() {
132    const sid = sessionModule.getCurrentSessionId();
133    const rec = Storage.getJSON(Storage.KEYS.PLAN, null);
134    return (rec && rec.sid === sid && rec.text) ? rec.text : '';
135  }
136  // A line like "- [ ] step" / "- [x] step" marks a GitHub-style checklist.
137  const _CHECKLIST_RE = /^\s*[-*]\s+\[[ xX]\]\s+/m;
138  // Exposed for app.js (plan-button menu) — re-open the stored plan window.
139  window._getStoredPlan = _getStoredPlan;
140  window.planWindowModule = planWindowModule;
141
142  /** Check if an SSE reader is still actively connected for a session. */
143  function hasActiveStream(sessionId) {
144    return _streamSessionId === sessionId || _backgroundStreams.has(sessionId) ||
145           _resumingStreams.has(sessionId);
146  }
147
148  // Sources box builder and toggleSources are now in chatRenderer.js
149  var _buildSourcesBox = chatRenderer.buildSourcesBox;
150
151  // Browser notifications now in chatStream.js
152  var _notifyResearchComplete = chatStream.notifyResearchComplete;
153
154  // Model/image pricing, _buildImageBubble now in chatRenderer.js
155  var _buildImageBubble = chatRenderer.buildImageBubble;
156  var getModelCost = chatRenderer.getModelCost;
157  var getImageCost = chatRenderer.getImageCost;
158
159  // stripToolBlocks and roleTimestamp now in chatRenderer.js
160  var stripToolBlocks = chatRenderer.stripToolBlocks;
161
162  function _normalizeEndpointForCompare(url) {
163    if (!url) return '';
164    try {
165      const u = new URL(String(url), window.location.origin);
166      let path = u.pathname.replace(/\/+$/, '');
167      const suffixes = [
168        '/v1/chat/completions', '/chat/completions',
169        '/v1/completions', '/completions',
170        '/v1/messages', '/messages',
171        '/v1/models', '/models',
172      ];
173      for (const suffix of suffixes) {
174        if (path.toLowerCase().endsWith(suffix)) {
175          path = path.slice(0, -suffix.length).replace(/\/+$/, '');
176          break;
177        }
178      }
179      return (u.origin + path).toLowerCase();
180    } catch (_) {
181      return String(url).trim().replace(/\/+$/, '').toLowerCase();
182    }
183  }
184
185  async function _probeCurrentEndpointStatus(endpointUrl, signal) {
186    const target = _normalizeEndpointForCompare(endpointUrl);
187    if (!target) return null;
188    const modelsRes = await fetch(`${API_BASE}/api/models`, { credentials: 'same-origin', signal });
189    if (!modelsRes.ok) return null;
190    const modelsData = await modelsRes.json().catch(() => ({}));
191    const item = (modelsData.items || []).find(ep =>
192      _normalizeEndpointForCompare(ep.url || ep.endpoint_url || ep.base_url) === target
193    );
194    if (!item || !item.endpoint_id) return null;
195
196    const probesRes = await fetch(`${API_BASE}/api/model-endpoints/probe-local`, {
197      credentials: 'same-origin',
198      signal,
199    });
200    if (!probesRes.ok) return null;
201    const probes = await probesRes.json().catch(() => ({}));
202    return probes[item.endpoint_id] || null;
203  }
204
205  /**
206   * Initialize with dependencies
207   */
208  export function init(apiBase) {
209    API_BASE = apiBase;
210    initSlashCommands({ apiBase, isStreaming: () => isStreaming });
211    // Initialize email inbox
212    emailInbox.init(documentModule);
213    // Wire the slash-command autocomplete popup on the chat composer. The
214    // dispatcher already handles the typed command — this just surfaces the
215    // registry as a discoverable menu when the user starts a message with /.
216    import('./slashAutocomplete.js').then(mod => {
217      const ta = document.getElementById('message');
218      if (ta && mod.initSlashAutocomplete) mod.initSlashAutocomplete(ta);
219    }).catch(() => {});
220  }
221
222  // addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen
223  // are now in chatRenderer.js — referenced via the public API delegation above.
224  var addMessage = chatRenderer.addMessage;
225  var createMsgFooter = chatRenderer.createMsgFooter;
226  var displayMetrics = chatRenderer.displayMetrics;
227  var hideWelcomeScreen = chatRenderer.hideWelcomeScreen;
228  var showWelcomeScreen = chatRenderer.showWelcomeScreen;
229
230  /**
231   * Update submit button state
232   */
233  function updateSubmitButton(state, submitBtn) {
234    if (!submitBtn) return;
235
236    if (state === 'streaming') {
237      // Clear any pending transitions from + → arrow swap
238      submitBtn.classList.remove('anim-spin', 'anim-spin-swap', 'anim-land', 'mic-mode', 'newchat-mode', 'newchat-expanded', 'recording');
239      // Ensure arrow icon is showing before launch
240      var icons = window._odysseusBtnIcons;
241      if (icons) submitBtn.innerHTML = icons.send;
242      void submitBtn.offsetWidth;
243      // Arrow launches up, then stop icon lands in
244      submitBtn.classList.add('anim-launch');
245      const _stopSvg = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
246      // Wait for the launch keyframe to finish (0.3s) before swapping the
247      // arrow out for the stop icon — otherwise the swap happens mid-flight
248      // and the user sees nothing fly out.
249      setTimeout(() => {
250        submitBtn.innerHTML = _stopSvg;
251        submitBtn.classList.remove('anim-launch');
252        void submitBtn.offsetWidth;
253        submitBtn.classList.add('anim-land');
254        submitBtn.addEventListener('animationend', () => submitBtn.classList.remove('anim-land'), { once: true });
255      }, 300);
256      submitBtn.title = 'Stop generation';
257      submitBtn.dataset.mode = 'streaming';
258      submitBtn.dataset.phase = 'processing';
259      isStreaming = true;
260      _startStallWatchdog();
261    } else if (state === 'idle') {
262      submitBtn.dataset.mode = '';
263      delete submitBtn.dataset.phase;
264      submitBtn.classList.remove('recording');
265      isStreaming = false;
266      _stopStallWatchdog();
267      // Defer to global updater which handles mic/newchat/send modes
268      if (window._updateSendBtnIcon) {
269        setTimeout(window._updateSendBtnIcon, 50);
270      } else {
271        var icons = window._odysseusBtnIcons;
272        submitBtn.innerHTML = icons ? icons.send : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
273        submitBtn.title = 'Send message';
274        submitBtn.classList.remove('mic-mode', 'newchat-mode');
275      }
276    }
277  }
278
279  // -----------------------------------------------------------------------
280  // Slash commands — now in slashCommands.js
281  // -----------------------------------------------------------------------
282
283  // API key pattern for the guard in handleChatSubmit
284  const API_KEY_RE = /^(sk-[a-zA-Z0-9_\-]{20,}|gsk_[a-zA-Z0-9]{20,}|AIza[a-zA-Z0-9_\-]{30,}|xai-[a-zA-Z0-9]{20,})$/;
285
286
287  /**
288   * Handle chat form submission
289   */
290  export async function handleChatSubmit(e) {
291    e.preventDefault();
292    // Cancel research clarification timeout if active
293    if (window._researchTimeoutTimer) {
294      clearTimeout(window._researchTimeoutTimer);
295      window._researchTimeoutTimer = null;
296    }
297    // Get current session
298    const sessionId = sessionModule.getCurrentSessionId();
299    const session = sessionModule.getSessions().find(s => s.id === sessionId);
300    
301    const submitBtn = document.querySelector('.send-btn');
302    
303    // If compare is active, stop all compare streams
304    if (window.compareModule && window.compareModule.isActive()) {
305      window.compareModule.handleCompareSubmit();
306      return;
307    }
308
309    // If currently streaming, stop it
310    if (isStreaming) {
311      // Cancel server-side research if in progress
312      const _cancelSid = sessionModule.getCurrentSessionId();
313      if (_cancelSid && _researchingStreamIds.has(_cancelSid)) {
314        fetch(`${API_BASE}/api/research/cancel/${_cancelSid}`, { method: 'POST' }).catch(e => console.warn('Research cancel failed:', e));
315        _researchingStreamIds.delete(_cancelSid);
316        _clearResearchTimer();
317      }
318      abortCurrentRequest(true);  // explicit user Stop → also cancel the detached server run
319
320      // Clean up any running agent thread nodes (stop wave animation, remove "running" state)
321      document.querySelectorAll('.agent-thread-node.running').forEach(node => {
322        if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; }
323        if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; }
324        node.classList.remove('running');
325        const wave = node.querySelector('.agent-thread-wave');
326        if (wave) wave.textContent = '';
327        const icon = node.querySelector('.agent-thread-icon');
328        if (icon) icon.textContent = '\u25A0'; // stop square
329        const statusEl = node.querySelector('.agent-thread-status');
330        if (!statusEl) {
331          const header = node.querySelector('.agent-thread-header');
332          if (header) {
333            const s = document.createElement('span');
334            s.className = 'agent-thread-status';
335            s.textContent = 'stopped';
336            header.appendChild(s);
337          }
338        }
339      });
340      document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming'));
341
342      // Clean up any thinking spinners
343      document.querySelectorAll('.agent-thinking-dots').forEach(el => {
344        if (el._spinner) el._spinner.destroy();
345        el.remove();
346      });
347      // No text accumulated — remove the empty holder with spinner
348      if (currentHolder && !currentAccumulated) {
349        if (currentSpinner) { currentSpinner.destroy(); currentSpinner = null; }
350        // Empty cancel — keep the assistant bubble around with a "Cancelled
351        // by user" indicator and persist a placeholder server-side so the
352        // turn survives a refresh instead of vanishing without a trace.
353        _renderCancelledBubble(currentHolder);
354        currentHolder = null;
355        updateSubmitButton('idle', submitBtn);
356        const messageInput = uiModule.el('message');
357        if (messageInput) messageInput.disabled = false;
358        currentAccumulated = '';
359        return;
360      }
361      // Render whatever was accumulated so far
362      if (currentHolder && currentAccumulated) {
363        // Store accumulated in a closure variable before it gets cleared
364        const stoppedContent = currentAccumulated;
365        
366        // Store raw content in dataset for consistency with other messages
367        currentHolder.dataset.raw = stoppedContent;
368        
369        currentHolder.querySelector('.body').innerHTML = markdownModule.processWithThinking(
370          markdownModule.squashOutsideCode(stoppedContent)
371        );
372        
373        // Highlight code blocks
374        if (window.hljs) {
375          currentHolder.querySelectorAll('pre code').forEach((block) => {
376            window.hljs.highlightElement(block);
377          });
378        }
379        
380        // Add the stopped indicator with continue button
381        const stoppedIndicator = document.createElement('div');
382        stoppedIndicator.className = 'stopped-indicator';
383        const stoppedLabel = document.createElement('span');
384        stoppedLabel.textContent = '[Message interrupted]';
385        stoppedIndicator.appendChild(stoppedLabel);
386        const continueBtn = document.createElement('button');
387        continueBtn.className = 'continue-btn';
388        continueBtn.title = 'Continue';
389        continueBtn.textContent = '\u25B8';
390        const _stoppedHolder = currentHolder; // capture before it gets cleared
391        continueBtn.addEventListener('click', () => {
392          stoppedIndicator.remove();
393          _hideUserBubble = true;
394          _pendingContinue = _stoppedHolder;
395          const cutoff = stoppedContent;
396          const msgInput = uiModule.el('message');
397          if (msgInput) {
398            msgInput.value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff.slice(-500) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.';
399            const sb = document.querySelector('.send-btn');
400            if (sb) sb.click();
401          }
402        });
403        stoppedIndicator.appendChild(continueBtn);
404        currentHolder.querySelector('.body').appendChild(stoppedIndicator);
405
406        // Tell server to mark this message as stopped
407        const _sid = sessionModule.getCurrentSessionId();
408        if (_sid) fetch(`${API_BASE}/api/session/${_sid}/mark-stopped`, { method: 'POST' }).catch(e => console.warn('mark-stopped failed:', e));
409
410        // Add footer with copy/regen if not already present
411        if (!currentHolder.querySelector('.msg-footer')) {
412          currentHolder.dataset.raw = stoppedContent;
413          currentHolder.appendChild(createMsgFooter(currentHolder));
414        }
415
416        uiModule.scrollHistory();
417      }
418      
419      // Reset button state
420      updateSubmitButton('idle', submitBtn);
421      
422      // Re-enable message input
423      const messageInput = uiModule.el('message');
424      if (messageInput) messageInput.disabled = false;
425      
426      // Clear tracking variables
427      currentAccumulated = '';
428      currentHolder = null;
429
430      return;
431    }
432
433    // --- Send-path entry: block re-clicks between submit and stream start ---
434    if (_sendInFlight) return;
435    _sendInFlight = true;
436    // Instant visual feedback so the user sees their click was accepted
437    // even before the streaming button state kicks in below.
438    const _earlyMessageInput = uiModule.el('message');
439    if (_earlyMessageInput) _earlyMessageInput.disabled = true;
440    if (submitBtn) submitBtn.classList.add('send-pending');
441    const _releaseSendFlag = () => {
442      _sendInFlight = false;
443      if (_earlyMessageInput) _earlyMessageInput.disabled = false;
444      if (submitBtn) submitBtn.classList.remove('send-pending');
445    };
446
447    // --- Setup mode: intercept next message (but let slash commands through) ---
448    {
449      const el = uiModule.el;
450      const rawMsg = (el('message').value || '').trim();
451      const currentSetupMode = slashCommands.getSetupMode();
452      if (currentSetupMode && rawMsg && !isCommand(rawMsg)) {
453        const mode = currentSetupMode;
454        slashCommands.clearSetupMode(mode === 'endpoint-provider' || mode === 'endpoint-key-for-provider');
455        el('message').value = '';
456        if (window._syncModelPickerAutohide) window._syncModelPickerAutohide();
457        if (uiModule.autoResize) uiModule.autoResize(el('message'));
458        if (mode === true || mode === 'endpoint') {
459          handleSetupInput(rawMsg);
460        } else {
461          handleSetupWizard(mode, rawMsg);
462        }
463        _releaseSendFlag();
464        return;
465      }
466      if (currentSetupMode && rawMsg && isCommand(rawMsg)) {
467        slashCommands.clearSetupMode();  // Clear setup mode, fall through to slash handler
468      }
469    }
470
471    const el = uiModule.el;
472    const msg = el('message').value;
473    // Allow empty text when a regen carries over the original message's
474    // attachment ids — a photo-only message still has something to send.
475    if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
476
477    // --- Slash commands: execute directly without AI (no session needed) ---
478    if (isCommand(msg.trim())) {
479      const handled = await handleSlashCommand(msg.trim());
480      if (handled) {
481        el('message').value = '';
482        if (window._syncModelPickerAutohide) window._syncModelPickerAutohide();
483        if (uiModule.autoResize) uiModule.autoResize(el('message'));
484        _releaseSendFlag();
485        return;
486      }
487    }
488
489    // Materialize pending session (deferred from model click) on first message
490    if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
491      const ok = await sessionModule.materializePendingSession();
492      if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
493    }
494
495    if (!sessionModule.getCurrentSessionId()) {
496      // Auto-create a session using default chat config. Always fetch fresh
497      // so that a recent Settings change takes effect without a page reload.
498      try {
499        let dc = null;
500        try {
501          const dcRes = await fetch('/api/default-chat');
502          dc = await dcRes.json();
503          if (dc && dc.endpoint_url && dc.model) {
504            try { window.__odysseusDefaultChat = dc; } catch (_) {}
505          }
506        } catch (_) {
507          dc = (typeof window !== 'undefined' && window.__odysseusDefaultChat) || null;
508        }
509        if (dc.endpoint_url && dc.model) {
510          await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
511          const ok = await sessionModule.materializePendingSession();
512          if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
513        } else {
514          el('message').value = '';
515          if (uiModule.autoResize) uiModule.autoResize(el('message'));
516          addMessage('assistant',
517            'No chat session active. You can:\n\n' +
518            '- Open the model picker in the chat box and pick a model\n' +
519            '- Use the `+` button in the model picker to add a model endpoint\n' +
520            '- Use `/help` to see all available commands');
521          _releaseSendFlag();
522          return;
523        }
524      } catch (e) {
525        el('message').value = '';
526        if (uiModule.autoResize) uiModule.autoResize(el('message'));
527        addMessage('assistant',
528          'No chat session active. You can:\n\n' +
529          '- Open the model picker in the chat box and pick a model\n' +
530          '- Use the `+` button in the model picker to add a model endpoint\n' +
531          '- Use `/help` to see all available commands');
532        _releaseSendFlag();
533        return;
534      }
535    }
536
537    // --- API key guard: warn if message looks like an API key ---
538    if (API_KEY_RE.test(msg.trim())) {
539      if (!await window.styledConfirm('This looks like an API key. Sending it to the AI could expose it.\n\nDid you mean to use /setup instead?', { confirmText: 'Send anyway', danger: true })) {
540        _releaseSendFlag();
541        return;
542      }
543    }
544
545
546    const messageInput = el('message');
547    const originalBtnText = submitBtn ? submitBtn.innerHTML : '';
548
549    // Re-enable the textarea now that we've handed off to the stream: the
550    // user wants to compose the next message while the AI is still talking.
551    // The `isStreaming` flag is the re-click guard for the send button.
552    if (messageInput) messageInput.disabled = false;
553    updateSubmitButton('streaming', submitBtn);
554    if (submitBtn) submitBtn.classList.remove('send-pending');
555    _sendInFlight = false;
556
557    // Capture session ID for background stream detection
558    const streamSessionId = sessionModule.getCurrentSessionId();
559    _streamSessionId = streamSessionId;
560    const streamQuery = msg;
561    _lastReaderActivity = Date.now();
562
563    // Acquire Web Lock to hint browser not to discard this tab while streaming
564    if (navigator.locks) {
565      navigator.locks.request('odysseus-stream-' + streamSessionId, { mode: 'exclusive', ifAvailable: true }, lock => {
566        if (!lock) return; // Another stream already holds a lock — fine
567        return new Promise(resolve => { _webLockRelease = resolve; });
568      }).catch(e => console.warn('web lock acquire failed:', e)); // Ignore lock errors — best-effort
569    }
570
571    // Declare accumulated outside try block so it's accessible in catch
572    let accumulated = '';
573    // Are we currently inside an unclosed <think> block? Toggled per think/answer
574    // cycle so a multi-round agent response (one reasoning phase PER round) wraps each
575    // round's reasoning in its own <think>…</think> instead of leaking rounds 2+ as text.
576    let _thinkOpen = false;
577    let holder = null;
578    let finalMeta = null;
579    let spinner = null;
580    let timedOut = false;
581    let processingProbeTimer = null;
582    let processingProbeAbort = null;
583    let _renderStream = () => {};
584    let _cancelThinkingTimer = () => {};
585    let _removeThinkingSpinner = () => {};
586    let timeoutId = null;
587    let responseTimeoutCleared = false;
588    let clearResponseTimeout = () => {};
589    const clearProcessingProbe = () => {
590      if (processingProbeTimer) {
591        clearTimeout(processingProbeTimer);
592        processingProbeTimer = null;
593      }
594      if (processingProbeAbort) {
595        try { processingProbeAbort.abort(); } catch (_) {}
596        processingProbeAbort = null;
597      }
598    };
599
600    // Reset tracking variables at start
601    currentAccumulated = '';
602    currentHolder = null;
603    
604    try {
605      // Re-enable auto-scroll when user sends a message
606      uiModule.setAutoScroll(true);
607      uiModule.scrollHistoryInstant();
608      // Clear completed dot now that user is interacting
609      if (sessionModule.clearStreamComplete) sessionModule.clearStreamComplete(sessionModule.getCurrentSessionId());
610
611      // Check for document selection context before consuming display override
612      const docSel = documentModule && documentModule.getSelectionContext();
613      if (docSel) {
614        const sels = Array.isArray(docSel) ? docSel : [docSel];
615        const lineRefs = sels.map(s =>
616          s.startLine === s.endLine ? `L${s.startLine}` : `L${s.startLine}-${s.endLine}`
617        );
618        _displayOverride = `[Doc edit: ${lineRefs.join(', ')}] ${msg}`;
619      }
620
621      const userDisplay = _displayOverride || msg;
622      _displayOverride = null;
623      const skipBubble = _hideUserBubble;
624      _hideUserBubble = false;
625      // Auto-recovery counter: carries across a turn's auto-continues, but resets
626      // when the user genuinely sends a new message (so each task gets a fresh cap).
627      // A real user turn (visible bubble) ALWAYS resets the budget — even if a
628      // prior auto-continue's deferred click never cleared the pending flag — so a
629      // stuck flag can't silently eat the next turn's recovery budget.
630      if (!skipBubble) { _autoNudges = 0; _autoContinuePending = false; }
631      else if (_autoContinuePending) { _autoContinuePending = false; }
632      const _pendingAttachInfo = fileHandlerModule.getPendingCount() ? fileHandlerModule.getPendingInfo() : null;
633      // Pre-read importable file contents before upload clears pending files
634      const IMPORTABLE_EXT = /\.(txt|py|js|ts|html|htm|css|md|json|csv|yml|yaml|sh|sql|rs|go|java|c|cpp|h|rb|php|xml|jsx|tsx|log|toml|ini|conf|env|vue|svelte|scss|sass|less)$/i;
635      const _importableFiles = [];
636      if (_pendingAttachInfo && documentModule) {
637        const rawFiles = fileHandlerModule.getPendingRaw ? fileHandlerModule.getPendingRaw() : [];
638        for (let i = 0; i < _pendingAttachInfo.length; i++) {
639          const att = _pendingAttachInfo[i];
640          if (IMPORTABLE_EXT.test(att.name) && rawFiles[i]) {
641            _importableFiles.push({ info: att, file: rawFiles[i] });
642          }
643        }
644      }
645      let _userMsgEl = null;
646      if (!skipBubble) {
647        _userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null);
648      }
649      messageInput.value = '';
650      messageInput.style.height = '';
651      messageInput.dispatchEvent(new Event('input'));
652      // Mobile: dismiss the on-screen keyboard after sending. iOS in
653      // particular ignores a bare blur() in some cases (or some other
654      // listener refocuses straight after), so we temporarily mark the
655      // input readonly which forces the keyboard to retract, then blur,
656      // then drop the readonly attribute after the keyboard is gone so
657      // typing still works for the next message.
658      if (window.innerWidth <= 768) {
659        try {
660          messageInput.setAttribute('readonly', 'readonly');
661          messageInput.blur();
662          const _dropReadonly = () => { try { messageInput.removeAttribute('readonly'); } catch {} };
663          setTimeout(() => {
664            // If the blur stuck, the input is no longer the active element —
665            // safe to drop readonly now so the next message can be typed.
666            // If it did NOT stick (some mobile browsers keep the textarea
667            // focused after a programmatic blur), removing readonly here would
668            // re-summon the keyboard mid-stream — the "bounce up" that then
669            // lingers until the end-of-stream blur. In that case keep readonly
670            // on (keyboard stays down) and drop it the moment the user taps to
671            // type again, so typing still works without the bounce.
672            if (document.activeElement === messageInput) {
673              messageInput.addEventListener('pointerdown', _dropReadonly, { once: true });
674              messageInput.addEventListener('focus', _dropReadonly, { once: true });
675            } else {
676              _dropReadonly();
677            }
678          }, 120);
679        } catch {}
680      }
681
682      let ids = [];
683      try {
684        ids = await fileHandlerModule.uploadPending();
685      } catch(e) {
686        console.error('upload failed', e);
687      }
688
689      // Carry over the original message's file-ids on a regenerate so the new
690      // send still references the same photos / docs (and picks up the user's
691      // edited OCR text via the server-side .vision cache). Always CONSUME the
692      // slot — even when empty / errored — so the regen ids can't bleed into
693      // an unrelated next message if uploadPending() above had thrown.
694      if (_pendingRegenAttachments && _pendingRegenAttachments.length) {
695        ids = ids.concat(_pendingRegenAttachments);
696      }
697      _pendingRegenAttachments = null;
698
699      // The optimistic user bubble was rendered before the upload assigned ids,
700      // so image previews couldn't show (the renderer needs att.id). Now that
701      // the upload resolved, stamp the ids — plus width/height for images so
702      // the skeleton can size itself to the photo's aspect ratio — and
703      // re-render so the thumbnail appears live, no refresh needed.
704      if (_userMsgEl && _pendingAttachInfo && ids.length) {
705        const _meta = fileHandlerModule.getLastUploadedMeta?.() || [];
706        for (let i = 0; i < _pendingAttachInfo.length && i < ids.length; i++) {
707          _pendingAttachInfo[i].id = ids[i];
708          const _m = _meta[i];
709          if (_m) {
710            if (_m.width)  _pendingAttachInfo[i].width  = _m.width;
711            if (_m.height) _pendingAttachInfo[i].height = _m.height;
712          }
713        }
714        chatRenderer.updateMessageAttachments(_userMsgEl, _pendingAttachInfo);
715      }
716
717      // Offer to import text files to document library
718      if (_importableFiles.length > 0) {
719        const existing = document.getElementById('import-prompt-banner');
720        if (existing) existing.remove();
721        const banner = document.createElement('div');
722        banner.id = 'import-prompt-banner';
723        banner.className = 'import-prompt-banner';
724        const label = _importableFiles.length === 1
725          ? `Import "${_importableFiles[0].info.name}" to document library?`
726          : `Import ${_importableFiles.length} files to document library?`;
727        const textEl = document.createElement('span');
728        textEl.textContent = label;
729        banner.appendChild(textEl);
730        const importBtn = document.createElement('button');
731        importBtn.textContent = 'Import';
732        importBtn.addEventListener('click', async () => {
733          importBtn.disabled = true;
734          importBtn.textContent = 'Importing…';
735          const EXT_LANG = {'.py':'python','.js':'javascript','.ts':'typescript','.html':'html','.css':'css','.md':'markdown','.json':'json','.yml':'yaml','.yaml':'yaml','.sh':'bash','.sql':'sql','.rs':'rust','.go':'go','.java':'java','.c':'c','.cpp':'cpp','.rb':'ruby','.php':'php','.xml':'xml','.jsx':'javascript','.tsx':'typescript'};
736          let imported = 0;
737          for (const { info, file } of _importableFiles) {
738            try {
739              const content = await file.text();
740              const dotIdx = info.name.lastIndexOf('.');
741              const title = dotIdx > 0 ? info.name.slice(0, dotIdx) : info.name;
742              const ext = dotIdx >= 0 ? info.name.slice(dotIdx).toLowerCase() : '';
743              await fetch(`${API_BASE}/api/document`, {
744                method: 'POST',
745                headers: { 'Content-Type': 'application/json' },
746                body: JSON.stringify({ title, language: EXT_LANG[ext] || '', content }),
747              });
748              imported++;
749            } catch (e) { console.error('Import failed:', info.name, e); }
750          }
751          banner.textContent = `Imported ${imported} file${imported !== 1 ? 's' : ''}`;
752          setTimeout(() => banner.remove(), 2000);
753        });
754        banner.appendChild(importBtn);
755        const dismissBtn = document.createElement('button');
756        dismissBtn.textContent = '\u00d7';
757        dismissBtn.className = 'import-prompt-dismiss';
758        dismissBtn.addEventListener('click', () => banner.remove());
759        banner.appendChild(dismissBtn);
760        const chatBar = document.getElementById('chat-bar');
761        if (chatBar) chatBar.parentNode.insertBefore(banner, chatBar);
762        // Auto-dismiss after 15 seconds
763        setTimeout(() => { if (banner.parentNode) banner.remove(); }, 15000);
764      }
765
766      // Auto-save document editor content before sending so the AI sees latest text
767      if (documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) {
768        try { await documentModule.saveDocument(); } catch(e) { console.warn('doc auto-save failed', e); }
769      }
770
771      // Inject document selection context if present
772      let finalMsg = msg;
773      if (docSel) {
774        const sels = Array.isArray(docSel) ? docSel : [docSel];
775        if (sels.length === 1) {
776          const s = sels[0];
777          const lineRef = s.startLine === s.endLine ? `line ${s.startLine}` : `lines ${s.startLine}-${s.endLine}`;
778          finalMsg = `In the document, edit this specific text (${lineRef}):\n\`\`\`\n${s.text}
778\n\`\`\`\n\nInstruction: ${msg}`;
779        } else {
780          const parts = sels.map((s, i) => {
781            const lineRef = s.startLine === s.endLine ? `line ${s.startLine}` : `lines ${s.startLine}-${s.endLine}`;
782            return `Selection ${i + 1} (${lineRef}):\n\`\`\`\n${s.text}\n\`\`\``;
783          });
784          finalMsg = `In the document, edit these specific sections:\n\n${parts.join('\n\n')}\n\nInstruction: ${msg}`;
785        }
786      }
787
788      // Apply inject prefix/suffix
789      const _inject = presetsModule.getInject ? presetsModule.getInject() : { prefix: '', suffix: '' };
790      let _finalMsgWithInject = finalMsg;
791      if (_inject.prefix) _finalMsgWithInject = _inject.prefix + ' ' + _finalMsgWithInject;
792      if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix;
793
794      const fd = new FormData();
795      fd.append('message', _finalMsgWithInject);
796      fd.append('session', streamSessionId);
797      if (ids.length) fd.append('attachments', JSON.stringify(ids));
798      // Auto-save & send active doc ID so the backend sees latest content
799      if (documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) {
800        try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ }
801        fd.append('active_doc_id', documentModule.getCurrentDocId());
802      }
803      // Web toggle: pre-search in Chat mode, tool permission in Agent mode
804      const toggleState = Storage.loadToggleState();
805      let isAgentMode = (toggleState.mode || 'chat') === 'agent';
806      // Auto-escalate to agent mode when a document is open — the user expects
807      // the AI to see the document and have tools to edit it
808      if (!isAgentMode && documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) {
809        isAgentMode = true;
810      }
811      fd.append('mode', isAgentMode ? 'agent' : 'chat');
812      if (el('web-toggle').checked) {
813        if (isAgentMode) {
814          fd.append('allow_web_search', 'true');
815        } else {
816          fd.append('use_web', 'true');
817        }
818      }
819      if (el('research-toggle').checked) {
820        fd.append('use_research', 'true');
821        // Research always runs in chat mode — override agent if set
822        fd.set('mode', 'chat');
823      }
824      if (el('bash-toggle').checked) {
825        fd.append('allow_bash', 'true');
826      }
827      // Plan mode: agent investigates read-only and proposes a plan to approve.
828      // Only meaningful in agent mode, and never alongside deep research.
829      // _forcePlanOff is a one-shot set by "Approve & Run" so the execution turn
830      // runs with full tools even though the Plan toggle is still on.
831      const _planToggle = el('plan-toggle');
832      const planTurn = !_forcePlanOff && isAgentMode && _planToggle && _planToggle.checked && !el('research-toggle').checked;
833      _forcePlanOff = false;
834      if (planTurn) {
835        fd.append('plan_mode', 'true');
836        fd.set('mode', 'agent');
837      } else if (isAgentMode) {
838        // Executing (not proposing): send the stored plan back so the backend
839        // pins it in context and the agent can always re-reference it.
840        const _sp = _getStoredPlan();
841        if (_sp) fd.append('approved_plan', _sp);
842      }
843      const ragChk = el('rag-toggle');
844      if (ragChk && !ragChk.checked) {
845        fd.append('use_rag', 'false');
846      }
847      const incognitoChk = el('incognito-toggle');
848      if (incognitoChk && incognitoChk.checked) {
849        fd.append('incognito', 'true');
850      }
851      const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
852      if (_ws) {
853        fd.append('workspace', _ws);
854      }
855      if (presetsModule.getSelectedPreset()) {
856        fd.append('preset_id', presetsModule.getSelectedPreset());
857      }
858
859
860      const abortCtrl = new AbortController();
861      abortCtrl._reason = '';
862      currentAbort = abortCtrl;
863
864      const _tState = Storage.loadToggleState();
865      const _isAgent = (_tState.mode || 'chat') === 'agent';
866
867      // Timeout: 6 min for research and agent mode, 3 min otherwise
868      const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
869      timeoutId = setTimeout(() => {
870        if (!abortCtrl.signal.aborted) {
871          timedOut = true;
872          abortCtrl._reason = 'timeout';
873          try {
874            if (streamSessionId) {
875              fetch(`/api/chat/stop/${encodeURIComponent(streamSessionId)}`, {
876                method: 'POST',
877                credentials: 'same-origin',
878              }).catch(() => {});
879            }
880          } catch (_) {}
881          abortCtrl.abort();
882        }
883      }, timeoutMs);
884      clearResponseTimeout = () => {
885        if (responseTimeoutCleared) return;
886        responseTimeoutCleared = true;
887        clearTimeout(timeoutId);
888      };
889      
890      const box = el('chat-history');
891      holder = document.createElement('div');
892      holder.className = 'msg msg-ai streaming';
893
894      // Track holder globally so stop button can access it
895      currentHolder = holder;
896      holder._researchQuery = msg; // Store query for notification text
897      
898      const modelName = sessionModule.getCurrentModel() || null;
899
900      let loadingText = 'Initializing...';
901
902      if (el('web-toggle').checked && !_isAgent) {
903        const _searchLabel = searchModule ? searchModule.getProviderLabel() : 'web';
904        loadingText = `Searching via ${_searchLabel}...<br>
905                       <span style="font-size: 0.9em; opacity: 0.8;">
906                       Query: "${msg.substring(0, 50)}${msg.length > 50 ? '...' : ''}"<br>
907                       Fetching top results...</span>`;
908      } else if (el('research-toggle').checked) {
909        loadingText = 'Deep research mode active...';
910      } else {
911        loadingText = 'Processing request...';
912      }
913
914      var roleLabel = _modelRouteLabel(modelName, modelName);
915      var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
916      if (_charNameInit) roleLabel = _charNameInit;
917      const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
918      holder.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
919      holder._requestedModel = modelName;
920      holder._actualModel = modelName;
921      _applyModelColor(holder.querySelector('.role'), modelName);
922      holder.style.position = 'relative';
923      
924      // Create spinner
925      spinner = spinnerModule.create('Initializing', 'right', 'wave');
926      currentSpinner = spinner;
927      const bodyDiv = holder.querySelector('.body');
928      bodyDiv.appendChild(spinner.createElement());
929      spinner.start();
930      
931      // Update spinner message based on mode
932      if (el('web-toggle').checked && !_isAgent) {
933        spinner.updateMessage('Searching web with ' + (searchModule ? searchModule.getProviderLabel() : 'SearXNG'));
934        setTimeout(() => spinner.updateMessage('Processing results'), 1500);
935      } else if (el('research-toggle').checked) {
936        spinner.updateMessage('Researching');
937        setTimeout(() => spinner.updateMessage('Analyzing sources'), 1500);
938      } else {
939        spinner.updateMessage('Processing request');
940        const endpointUrlForProbe = sessionModule.getCurrentEndpointUrl ? sessionModule.getCurrentEndpointUrl() : null;
941        if (endpointUrlForProbe && modelName) {
942          processingProbeTimer = setTimeout(async () => {
943            processingProbeTimer = null;
944            if (accumulated || !spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted)) return;
945            processingProbeAbort = new AbortController();
946            try {
947              spinner.updateMessage('Checking model endpoint');
948              const status = await _probeCurrentEndpointStatus(endpointUrlForProbe, processingProbeAbort.signal);
949              if (accumulated || !spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted)) return;
950              if (!status) {
951                spinner.updateMessage('Still waiting for model');
952              } else if (status.alive) {
953                const latency = status.latency_ms ? ` (${status.latency_ms}ms)` : '';
954                spinner.updateMessage(`Endpoint online${latency}; waiting for first token`);
955              } else {
956                // Probe confirms the endpoint isn't responding. Don't
957                // sit on a hung fetch — give the user 5s to read the
958                // status, then auto-abort with reason='offline' so the
959                // catch handler shows a clean "switch model" message
960                // instead of leaving the spinner spinning forever.
961                if (status.error) console.warn('Model endpoint probe failed:', status.error);
962                let _countdown = 5;
963                spinner.updateMessage(`Endpoint offline — cancelling in ${_countdown}s`);
964                const _tick = setInterval(() => {
965                  _countdown--;
966                  if (!spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted) || accumulated) {
967                    clearInterval(_tick);
968                    return;
969                  }
970                  if (_countdown > 0) {
971                    spinner.updateMessage(`Endpoint offline — cancelling in ${_countdown}s`);
972                  } else {
973                    clearInterval(_tick);
974                    if (currentAbort && !currentAbort.signal.aborted) {
975                      currentAbort._reason = 'offline';
976                      currentAbort.abort();
977                    }
978                  }
979                }, 1000);
980              }
981            } catch (e) {
982              if (e && e.name !== 'AbortError' && spinner && spinner.element && !accumulated) {
983                spinner.updateMessage('Still waiting for model');
984              }
985            } finally {
986              processingProbeAbort = null;
987            }
988          }, 10000);
989        }
990      }
991      
992      const researchBtn = el('research-toggle-btn');
993      if (el('research-toggle').checked && researchBtn) {
994        researchBtn.disabled = true;
995        researchBtn.classList.remove('active');
996      }
997      box.appendChild(holder);
998      uiModule.scrollHistory();
999
1000      const enableResearchBtn = () => {
1001        if (!researchBtn) return;
1002        researchBtn.disabled = false;
1003        researchBtn.classList.toggle('active', el('research-toggle').checked);
1004      };
1005
1006      if (el('research-toggle').checked && researchBtn) {
1007        researchBtn.style.display = 'none';
1008        // Uncheck research toggle so follow-up messages don't trigger another research
1009        el('research-toggle').checked = false;
1010      }
1011
1012      // User's current UTC offset in minutes (east of UTC). Threaded into
1013      // the agent so natural-language times like "today at 9pm" are
1014      // interpreted in YOUR timezone, not the server's.
1015      const _tzOffsetMin = -new Date().getTimezoneOffset();
1016      const _tzName = (() => {
1017        try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; }
1018        catch { return ''; }
1019      })();
1020      const res = await fetch(`${API_BASE}/api/chat_stream`, {
1021        method: 'POST',
1022        body: fd,
1023        headers: { 'X-Tz-Offset': String(_tzOffsetMin), 'X-Tz-Name': _tzName },
1024        signal: abortCtrl.signal
1025      });
1026      
1027      if (!res.ok) {
1028        clearResponseTimeout();
1029        if (res.status === 404) {
1030          // Session was deleted (e.g. by AI) — reload and go to welcome
1031          holder.remove();
1032          if (sessionModule) await sessionModule.loadSessions();
1033          return;
1034        }
1035        let errText = `Error ${res.status}`;
1036        try {
1037          const errBody = await res.text();
1038          // Parse nested JSON error if present
1039          const m = errBody.match(/"message"\s*:\s*"([^"]+)"/);
1040          if (m) errText = m[1].replace(/\\"/g, '"');
1041          else if (errBody.length < 200) errText = errBody;
1042        } catch {}
1043        // Auto-switch to chat mode for tool-related errors
1044        if (errText.includes('tool') || errText.includes('auto')) {
1045          errText = 'This model doesn\'t support agent tools — switched to Chat mode. Try again.';
1046          const _ab = document.getElementById('mode-agent-btn');
1047          const _cb = document.getElementById('mode-chat-btn');
1048          if (_ab && _cb) {
1049            _ab.classList.remove('active');
1050            _cb.classList.add('active');
1051            const _toggle = _ab.closest('.mode-toggle');
1052            if (_toggle) _toggle.classList.add('mode-chat');
1053          }
1054          if (typeof Storage !== 'undefined' && Storage.KEYS) {
1055            const _st = Storage.getJSON(Storage.KEYS.TOGGLES, {});
1056            _st.mode = 'chat';
1057            Storage.setJSON(Storage.KEYS.TOGGLES, _st);
1058          }
1059        }
1060        typewriterInto(holder.querySelector('.body'), errText);
1061        enableResearchBtn();
1062        return;
1063      }
1064
1065      // Mark the chat log busy while streaming so screen readers wait for the
1066      // settled response instead of announcing every token. Cleared in finally.
1067      const _chatLog = document.getElementById('chat-history');
1068      if (_chatLog) _chatLog.setAttribute('aria-busy', 'true');
1069
1070      const reader = res.body.getReader();
1071      const decoder = new TextDecoder();
1072      let buffer = '';
1073      let metrics = null;
1074      let isThinking = false;
1075      let thinkingStartTime = null;
1076      // Streaming TTS: synthesize sentence-by-sentence during streaming
1077      const streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available);
1078      if (streamingTTS) window.aiTTSManager.streamingStart();
1079      // Multi-bubble agent tracking
1080      let roundHolder = holder;       // Current AI text bubble (changes per round)
1081      let roundText = '';             // Text accumulated for current round
1082      let currentToolBubble = null;   // Current tool execution bubble
1083      let roundFinalized = false;     // Whether current round's text is finalized
1084      let _sourcesHtml = '';          // Sources box HTML to prepend to body
1085      let _sourcesExpanded = false;   // Track if user expanded sources during stream
1086      let _sourcesData = null;        // Raw sources data for rebuilding
1087      let _sourcesType = '';          // 'web' or 'research'
1088      let _findingsData = null;      // Raw findings data for collapsible box
1089      // _keepResearchOn removed — clarification state now persisted server-side via DB mode
1090      // Insert sources box as a stable DOM node that won't be replaced during streaming.
1091      // Returns the content container to use for innerHTML updates.
1092      function _ensureStreamLayout(body) {
1093        if (!body) return body;
1094        // Sources are deferred to final render — don't insert during streaming
1095        // Ensure a stable content div exists for text content
1096        var contentDiv = body.querySelector('.stream-content');
1097        if (!contentDiv) {
1098          contentDiv = document.createElement('div');
1099          contentDiv.className = 'stream-content';
1100          body.appendChild(contentDiv);
1101        }
1102        return contentDiv;
1103      }
1104      const esc = uiModule.esc;
1105      // Remove thinking spinner helper
1106      _removeThinkingSpinner = () => {
1107        const el = document.querySelector('.agent-thinking-dots');
1108        if (el) {
1109          if (el._spinner) el._spinner.destroy();
1110          el.remove();
1111        }
1112      };
1113
1114      // Tool-aware thinking spinner
1115      let _lastToolName = '';
1116      const _searchIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
1117      const _toolLabels = {
1118        'web_search': _searchIcon + 'Searching',
1119        'bash': 'Running',
1120        'python': 'Running',
1121        'create_document': 'Writing',
1122        'update_document': 'Writing',
1123        'read_document': 'Reading',
1124        'edit_file': 'Editing',
1125        'read_file': 'Reading',
1126        'write_file': 'Writing',
1127        'list_files': 'Browsing',
1128        'image_gen': 'Generating',
1129        'generate_image': 'Generating',
1130        'manage_memory': 'Remembering',
1131        'save_memory': 'Remembering',
1132        'search_memory': 'Recalling',
1133        'manage_session': 'Organizing',
1134        'deep_research': 'Researching',
1135        'list_models': 'Browsing',
1136        'ui_control': 'Adjusting',
1137      };
1138      function _thinkingLabel() {
1139        if (!_lastToolName) {
1140          return 'Thinking';
1141        }
1142        // Check exact match first, then prefix match
1143        const lower = _lastToolName.toLowerCase();
1144        if (_toolLabels[lower]) return _toolLabels[lower];
1145        for (const [key, label] of Object.entries(_toolLabels)) {
1146          if (lower.includes(key) || key.includes(lower)) return label;
1147        }
1148        return 'Thinking';
1149      }
1150
1151      function _showThinkingSpinner(label) {
1152        if (document.querySelector('.agent-thinking-dots')) return;
1153        const _thinkMsg = document.createElement('div');
1154        _thinkMsg.className = 'msg msg-ai agent-thinking-dots';
1155        const _thinkBody = document.createElement('div');
1156        _thinkBody.className = 'body';
1157        const _ts = spinnerModule.create(label || 'Thinking', 'right', 'wave');
1158        _thinkBody.appendChild(_ts.createElement());
1159        _ts.start(120);
1160        _thinkMsg._spinner = _ts;
1161        _thinkMsg.appendChild(_thinkBody);
1162        document.getElementById('chat-history').appendChild(_thinkMsg);
1163        uiModule.scrollHistory();
1164      }
1165
1166      // Auto-show thinking spinner after text stops streaming
1167      let _textPauseTimer = null;
1168      function _scheduleThinkingSpinner() {
1169        if (_textPauseTimer) clearTimeout(_textPauseTimer);
1170        _textPauseTimer = setTimeout(() => {
1171          if (!document.querySelector('.agent-thinking-dots') && isStreaming) {
1172            _showThinkingSpinner(_thinkingLabel());
1173          }
1174        }, 400);
1175      }
1176      _cancelThinkingTimer = () => {
1177        if (_textPauseTimer) { clearTimeout(_textPauseTimer); _textPauseTimer = null; }
1178      };
1179
1180      // Document streaming state (text-fence detection)
1181      let _docFenceOpened = false;
1182      let _docFenceContentStart = -1;
1183      let _liveThinkSection = null;
1184      let _liveThinkContent = null;
1185      let _liveThinkInner = null;
1186      let _liveThinkHeader = null;
1187      let _liveThinkSpinnerSlot = null;
1188      let _liveThinkTimerEl = null;
1189      let _liveThinkToggle = null;
1190      let _liveThinkDomId = null;
1191
1192      function _replyAfterClosedThinking(text) {
1193        const closeRe = /<\/(?:think(?:ing)?|thought)>|<channel\|>/gi;
1194        let match = null;
1195        let last = null;
1196        while ((match = closeRe.exec(text || '')) !== null) last = match;
1197        if (!last) return '';
1198        return (text || '').slice(last.index + last[0].length).trimStart();
1199      }
1200
1201      // Direct render helper for streaming text
1202      _renderStream = () => {
1203        let dt = stripToolBlocks(roundText);
1204        const bodyEl = roundHolder.querySelector('.body');
1205        const contentEl = _ensureStreamLayout(bodyEl);
1206
1207        // If thinking was already collapsed in-place, only render the reply portion
1208        let liveReply = contentEl.querySelector('.live-reply-content');
1209        if (liveReply) {
1210          // Extract reply text — handle native <think> tags and non-tag patterns
1211          const closedThinkReply = _replyAfterClosedThinking(dt);
1212          const { thinkingBlocks, content: replyText } = closedThinkReply
1213            ? { thinkingBlocks: [''], content: closedThinkReply }
1214            : markdownModule.extractThinkingBlocks(dt);
1215          let replyTrimmed = '';
1216          if (thinkingBlocks.length) {
1217            replyTrimmed = (replyText || '').trim();
1218          } else {
1219            // Non-tag: check for garbled <think> (reasoning\n<think>reply)
1220            const _gm = dt.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
1221            if (_gm && _gm[1].trim()) {
1222              replyTrimmed = _gm[1].trim();
1223            } else {
1224              // Pure non-tag: find reply boundary
1225              const _rPrefixes = markdownModule.startsWithReasoningPrefix;
1226              const _rpStarts = ['Hey', 'Hi ', 'Hi!', 'Hello', '
1226Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
1227              const _rt = (replyText || '').trimStart();
1228              if (_rPrefixes(_rt)) {
1229                const _rLines = _rt.split('\n');
1230                for (let _ri = 1; _ri < _rLines.length; _ri++) {
1231                  const _rl = _rLines[_ri].trim();
1232                  if (!_rl) continue;
1233                  if (_rpStarts.some(rp => _rl.startsWith(rp))) { replyTrimmed = _rLines.slice(_ri).join('\n'); break; }
1234                }
1235                if (!replyTrimmed) {
1236                  for (const rp of _rpStarts) {
1237                    const rx = new RegExp('[.!?]\\s*(' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')');
1238                    const m = rx.exec(_rt);
1239                    if (m && m.index > 20) { replyTrimmed = _rt.slice(m.index + 1).trim(); break; }
1240                  }
1241                }
1242              }
1243            }
1244          }
1245          if (replyTrimmed) {
1246            const r = liveReply._streamRenderer ||
1247              (liveReply._streamRenderer = createStreamRenderer(liveReply, {
1248                render: (t) => markdownModule.mdToHtml(markdownModule.squashOutsideCode(t)),
1249                hljs: window.hljs,
1250              }));
1251            r.update(replyTrimmed);
1252          }
1253          // Reply empty or not — preserve thinking bar, don't fall through to full re-render
1254          uiModule.scrollHistory();
1255          return;
1256        }
1257
1258        // If thinking is still streaming (unclosed <think>), show indicator instead of raw text
1259        if (markdownModule.hasUnclosedThinkTag && markdownModule.hasUnclosedThinkTag(dt)) {
1260          const thinkStart = dt.search(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i);
1261          const thinkContent = dt.substring(Math.max(thinkStart, 0))
1262            .replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought\s*\n?/i, '')
1263            .replace(/<channel\|>/gi, '')
1264            .trim();
1265          const lines = thinkContent.split('\n').length;
1266          // Don't show beforeThink text during streaming — it'll appear in the final render
1267          // This prevents the "split into two" duplication
1268          contentEl.innerHTML =
1269            '<div class="thinking-section"><div class="thinking-header"><div class="thinking-header-left">Thinking' +
1270            (lines > 1 ? ` (${lines} lines)` : '') + '</div></div></div>';
1271          // The stream renderer self-heals when it next sees this overwritten
1272          // container (streamingRenderer.js), so no explicit reset is needed here.
1273          uiModule.scrollHistory();
1274          return;
1275        }
1276
1277        // Incremental streaming render: freeze finalized blocks, re-render only the
1278        // growing tail, and highlight each code block once on completion. This is
1279        // what keeps code-block hover buttons from flickering and avoids the O(N^2)
1280        // re-parse/re-highlight of the whole message on every token.
1281        // See streamingRenderer.js / streamingSegmenter.js.
1282        const renderer = contentEl._streamRenderer ||
1283          (contentEl._streamRenderer = createStreamRenderer(contentEl, {
1284            render: (t) => markdownModule.processWithThinking(markdownModule.squashOutsideCode(t)),
1285            hljs: window.hljs,
1286          }));
1287        renderer.update(dt);
1288        uiModule.scrollHistory();
1289      };
1290
1291      let _nextIsError = false;
1292      let _streamSawDone = false;
1293
1294      while (true) {
1295        const { done, value } = await reader.read();
1296        _lastReaderActivity = Date.now();
1297        if (done) break;
1298
1299        buffer += decoder.decode(value, { stream: true });
1300        const lines = buffer.split('\n');
1301        buffer = lines.pop() || '';
1302
1303        for (const line of lines) {
1304          // Log SSE event types (e.g. "event: error") for debugging
1305          if (line.startsWith('event: ')) {
1306            const evtType = line.slice(7).trim();
1307            if (evtType === 'error') _nextIsError = true;
1308            continue;
1309          }
1310          if (line.startsWith('data: ')) {
1311            const data = line.slice(6);
1312
1313            // (thinking spinner removal is handled in agent_step / tool_start / content handlers)
1314
1315            // Background detection: are we on a different session?
1316            const _isBg = (sessionModule.getCurrentSessionId() !== streamSessionId);
1317
1318            // On first transition to background, store state in map
1319            if (_isBg && !_backgroundStreams.has(streamSessionId)) {
1320              _backgroundStreams.set(streamSessionId, {
1321                status: 'running',
1322                accumulated: accumulated,
1323                sourcesHtml: _sourcesHtml,
1324                findingsData: null,
1325                abortCtrl: currentAbort,
1326                query: streamQuery,
1327                metrics: null,
1328              });
1329              if (sessionModule && sessionModule.markStreaming) {
1330                sessionModule.markStreaming(streamSessionId);
1331              }
1332            }
1333
1334            if (data === '[DONE]') {
1335              _streamSawDone = true;
1336              // Always update background map if entry exists (even if user switched back)
1337              var bgDone = _backgroundStreams.get(streamSessionId);
1338              if (bgDone) {
1339                bgDone.status = 'completed';
1340                bgDone.accumulated = accumulated;
1341                if (_isBg) {
1342                  try {
1343                    _notifyStreamComplete(streamSessionId, streamQuery);
1344                    _insertStreamDoneToast(streamSessionId, streamQuery);
1345                  } catch (toastErr) {
1346                    console.warn('[bg-stream] Toast/notification error:', toastErr);
1347                  }
1348                }
1349                // CRITICAL: always mark stream complete for the sidebar dot
1350                try {
1351                  if (sessionModule && sessionModule.markStreamComplete) {
1352                    sessionModule.markStreamComplete(streamSessionId);
1353                  }
1354                } catch (dotErr) {
1355                  console.warn('[bg-stream] markStreamComplete error:', dotErr);
1356                }
1357                // Don't do foreground final render — the checkBackgroundStream poll
1358                // will detect 'completed' and reload history cleanly
1359                break;
1360              }
1361              // Force-close thinking if still open (model never output boundary)
1362              if (isThinking) {
1363                isThinking = false;
1364                cancelAnimationFrame(_thinkTimerRAF);
1365                var _elapsedDone = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
1366                if (_elapsedDone) {
1367                  accumulated = accumulated.replace(/<think>/i, '<think time="' + _elapsedDone + '">');
1368                  roundText = roundText.replace(/<think>/i, '<think time="' + _elapsedDone + '">');
1369                }
1370                if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
1371                if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
1372                if (_liveThinkTimerEl && _elapsedDone) {
1373                  _liveThinkTimerEl.textContent = _elapsedDone + 's';
1374                  _liveThinkTimerEl.style.marginLeft = 'auto';
1375                  _liveThinkTimerEl.style.marginRight = '5px';
1376                  var _hdrDone = _liveThinkTimerEl.closest('.thinking-header');
1377                  // Keep the chevron furthest right with the timer to its left
1378                  // (match the live + final-render layout) — insert before the
1379                  // toggle rather than appending (which would land after it).
1380                  if (_hdrDone) {
1381                    if (_liveThinkToggle && _liveThinkToggle.parentElement === _hdrDone)
1382                      _hdrDone.insertBefore(_liveThinkTimerEl, _liveThinkToggle);
1383                    else _hdrDone.appendChild(_liveThinkTimerEl);
1384                  }
1385                }
1386                // Assign stable IDs
1387                var _thinkIdDone = 'think-' + Date.now();
1388                var _liveHdrDone = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header');
1389                if (_liveHdrDone) _liveHdrDone.dataset.thinkingId = _thinkIdDone;
1390                if (_liveThinkContent) _liveThinkContent.id = _thinkIdDone;
1391                if (_liveThinkToggle) _liveThinkToggle.id = _thinkIdDone + '-toggle';
1392                // Create live-reply container so final render preserves thinking bar
1393                var _streamElDone = _liveThinkSection ? _liveThinkSection.parentElement : roundHolder.querySelector('.stream-content');
1394                if (!_streamElDone) _streamElDone = roundHolder.querySelector('.body');
1395                if (_streamElDone && !_streamElDone.querySelector('.live-reply-content')) {
1396                  var _replyElDone = document.createElement('div');
1397                  _replyElDone.className = 'live-reply-content';
1398                  _streamElDone.appendChild(_replyElDone);
1399                }
1400              }
1401              // Normal foreground completion — metrics will be displayed in the final render block below
1402              break;
1403            }
1404            try {
1405              const json = JSON.parse(data);
1406              // Handle SSE error events (e.g. HTTP 404 from provider)
1407              if (_nextIsError || json.status >= 400) {
1408                _nextIsError = false;
1409                const errMsg = json.text || json.error?.message || `Error ${json.status || 'unknown'}`;
1410                console.error('Stream error:', errMsg);
1411                if (spinner && spinner.element) spinner.destroy();
1412                typewriterInto(roundHolder.querySelector('.body'), errMsg);
1413                break;
1414              }
1415              if (json.delta || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
1416                clearResponseTimeout();
1417                clearProcessingProbe();
1418              }
1419              if (json.delta) {
1420                _cancelThinkingTimer();
1421                _removeThinkingSpinner();
1422                // Text arrived after tools — connect thread line to this bubble
1423                const _threadAbove = roundHolder?.previousElementSibling;
1424                if (_threadAbove && _threadAbove.classList.contains('agent-thread') && !_threadAbove.classList.contains('has-bottom')) {
1425                  _threadAbove.classList.add('has-bottom');
1426                }
1427                // VLLM reasoning tokens: wrap in <think> tags for the thinking UI.
1428                // Stateful open/close (not a whole-message substring check) so each round
1429                // of a multi-round agent response gets its own <think>…</think> — otherwise
1430                // only round 1 is wrapped and rounds 2+ reasoning leaks into the answer.
1431                let _delta = json.delta;
1432                if (json.thinking) {
1433                  if (!_thinkOpen) { _delta = '<think>' + _delta; _thinkOpen = true; }
1434                } else if (_thinkOpen) {
1435                  _delta = '</think>' + _delta; _thinkOpen = false;
1436                }
1437                const wasEmpty = !accumulated;
1438                accumulated += _delta;
1439                roundText += _delta;
1440                currentAccumulated = accumulated; // Update global tracker
1441                // First token arrived — switch stop button from processing to streaming
1442                if (wasEmpty && submitBtn && !_isBg) {
1443                  submitBtn.dataset.phase = 'receiving';
1444                }
1445
1446                // Update background map if running in background
1447                if (_isBg) {
1448                  var bgEntry = _backgroundStreams.get(streamSessionId);
1449                  if (bgEntry) bgEntry.accumulated = accumulated;
1450                  continue; // Skip all DOM writes
1451                }
1452
1453                // --- Text-fence doc streaming (for models that don't use native tool calls) ---
1454                if (!_docFenceOpened && documentModule && roundText.includes('```create_document\n')) {
1455                  const fenceIdx = roundText.indexOf('```create_document\n');
1456                  const afterFence = roundText.slice(fenceIdx + '```create_document\n'.length);
1457                  const fenceLines = afterFence.split('\n');
1458                  if (fenceLines.length >= 1 && fenceLines[0].trim()) {
1459                    _docFenceOpened = true;
1460                    const title = fenceLines[0].trim();
1461                    // Keep in sync with backend _KNOWN_LANGS in src/tool_implementations.py
1462                    const knownLangs = ['python','py','javascript','js','typescript','ts','html','css','json','yaml','bash','sql','rust','go','java','c','cpp','markdown','text','plain','ruby','swift','kotlin','php','email','csv','xml','toml','ini'];
1463                    const isLang = fenceLines.length >= 2 && knownLangs.includes(fenceLines[1].trim().toLowerCase());
1464                    const lang = isLang ? fenceLines[1].trim() : '';
1465                    _docFenceContentStart = fenceIdx + '```create_document\n'.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0);
1466                    documentModule.streamDocOpen(title, lang);
1467                  }
1468                }
1469                if (_docFenceOpened && _docFenceContentStart > 0 && documentModule) {
1470                  let raw = roundText.slice(_docFenceContentStart);
1471                  const closeIdx = raw.indexOf('\n```');
1472                  if (closeIdx >= 0) raw = raw.slice(0, closeIdx);
1473                  documentModule.streamDocDelta(raw);
1474                }
1475
1476                // Detect thinking-in-progress:
1477                // 1. Normal: <think>...no closing tag yet
1478                // 2. Malformed: <think></think>\n...text but no second </think> yet
1479                // 3. Qwen3.5: "Thinking Process:" without <think> tags
1480                let hasUnclosedThink = markdownModule.hasUnclosedThinkTag(roundText);
1481                // Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning
1482                // These patterns don't use <think> tags, so we simulate unclosed thinking during streaming
1483                const _replyPrefixes = ['Hey', 'Hi ', 'Hi!', 'Hello', '
1483Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
1484                if (!hasUnclosedThink && !/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i.test(roundText)) {
1485                  const _trimmedRT = roundText.trimStart();
1486                  const _isReasoning = markdownModule.startsWithReasoningPrefix(_trimmedRT);
1487                  if (_isReasoning) {
1488                    // Check if we can see a reply boundary yet (newline then reply pattern)
1489                    const _lines = _trimmedRT.split('\n');
1490                    let _replyFound = false;
1491                    for (let li = 1; li < _lines.length; li++) {
1492                      const _l = _lines[li].trim();
1493                      if (!_l) continue;
1494                      if (_replyPrefixes.some(rp => _l.startsWith(rp))) {
1495                        _replyFound = true;
1496                        break;
1497                      }
1498                    }
1499                    if (!_replyFound) {
1500                      // Also check within-line: "reasoning text.Reply text"
1501                      const _inlineReply = _replyPrefixes.some(rp => {
1502                        const rx = new RegExp('[.!?]\\s*' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
1503                        const m = rx.exec(_trimmedRT);
1504                        return m && m.index > 20;
1505                      });
1506                      if (!_inlineReply) hasUnclosedThink = true;
1507                    }
1508                  }
1509                }
1510                if (!hasUnclosedThink && /^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i.test(roundText)) {
1511                  // Empty <think></think> — the model likely put thinking outside the tags
1512                  const afterEmpty = roundText.replace(/^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i, '').trim();
1513                  const closeTags = (afterEmpty.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length;
1514                  if (closeTags === 0 && afterEmpty.length > 0) {
1515                    hasUnclosedThink = true; // still waiting for real closing tag
1516                  }
1517                }
1518                // Detect false close: <think>short</think> where real thinking follows untagged
1519                // Only applies when there's a second </think> later (model leaked thinking outside tags)
1520                // Do NOT trigger if the text after </think> contains tool calls (that's real content)
1521                if (!hasUnclosedThink && isThinking) {
1522                  const _thinkMatch = roundText.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i);
1523                  const _thinkLen = _thinkMatch ? _thinkMatch[1].trim().length : 0;
1524                  if (_thinkLen < 20) {
1525                    const _afterClose = roundText.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i, '').trim();
1526                    // Only keep waiting if there's trailing text that looks like thinking (not tool calls)
1527                    const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i.test(_afterClose);
1528                    const _hasOrphanClose = /<\/(?:think(?:ing)?|thought)>/i.test(_afterClose);
1529                    if (!_hasToolCall && (_hasOrphanClose || (Date.now() - thinkingStartTime) < 500)) {
1530                      hasUnclosedThink = true; // keep waiting for real </think>
1531                    }
1532                  }
1533                }
1534
1535                if (hasUnclosedThink && !isThinking) {
1536                  isThinking = true;
1537                  thinkingStartTime = Date.now();
1538                  if (spinner && spinner.element) spinner.destroy();
1539
1540                  // Create a live thinking box — starts expanded so content streams visibly
1541                  var thinkBody = roundHolder.querySelector('.body');
1542                  var thinkContent = _ensureStreamLayout(thinkBody);
1543                  thinkContent.style.minHeight = '';
1544                  _liveThinkDomId = 'live-think-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
1545                  thinkContent.innerHTML = `
1546                    <div class="thinking-section">
1547                      <div class="thinking-header" data-thinking-id="${_liveThinkDomId}">
1548                        <div class="thinking-header-left"><span class="live-think-header-text">Thinking\u2026</span></div>
1549                        <span class="live-think-spinner-slot" style="flex-shrink:0;margin-left:auto;"></span>
1550                        <span class="live-think-timer" style="font-size:11px;opacity:0.4;font-variant-numeric:tabular-nums;margin-left:6px;margin-right:5px;"></span>
1551                        <span class="thinking-toggle live-think-toggle" id="${_liveThinkDomId}-toggle"></span>
1552                      </div>
1553                      <div class="thinking-content" id="${_liveThinkDomId}">
1554                        <div class="thinking-content-inner live-think-inner"></div>
1555                      </div>
1556                    </div>`;
1557                  _liveThinkSection = thinkContent.querySelector('.thinking-section');
1558                  _liveThinkContent = thinkContent.querySelector('.thinking-content');
1559                  _liveThinkInner = thinkContent.querySelector('.live-think-inner');
1560                  _liveThinkHeader = thinkContent.querySelector('.live-think-header-text');
1561                  _liveThinkSpinnerSlot = thinkContent.querySelector('.live-think-spinner-slot');
1562                  _liveThinkTimerEl = thinkContent.querySelector('.live-think-timer');
1563                  _liveThinkToggle = thinkContent.querySelector('.live-think-toggle');
1564                  // Live timer
1565                  var _thinkTimerStart = Date.now();
1566                  var _thinkTimerRAF = 0;
1567                  function _tickThinkTimer() {
1568                    if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) return;
1569                    var s = ((Date.now() - _thinkTimerStart) / 1000).toFixed(1);
1570                    _liveThinkTimerEl.textContent = s + 's';
1571                    _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
1572                  }
1573                  _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
1574                  // Whirlpool spinner
1575                  if (_liveThinkSpinnerSlot) {
1576                    var _wp = spinnerModule.createWhirlpool(12);
1577                    _wp.element.style.margin = '0';
1578                    _wp.element.style.width = '12px';
1579                    _wp.element.style.height = '12px';
1580                    _wp.element.style.transform = 'translateY(-1px)'; // align the whirlpool with the header text
1581                    _liveThinkSpinnerSlot.appendChild(_wp.element);
1582                  }
1583                } else if (hasUnclosedThink && isThinking) {
1584                  if (_liveThinkInner) {
1585                    // Extract raw thinking text (strip known thinking wrappers and prefixes)
1586                    var thinkText = roundText
1587                      .replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
1588                      .replace(/<\|channel>thought\s*\n?/gi, '')
1589                      .replace(/<\|channel>response\s*\n?/gi, '')
1590                      .replace(/<channel\|>/gi, '');
1591                    thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
1592                    _liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
1593                    // Keep thinking box scrolled to bottom
1594                    var thinkBox = _liveThinkInner.closest('.thinking-content');
1595                    if (thinkBox) thinkBox.scrollTop = thinkBox.scrollHeight;
1596                  }
1597                  uiModule.scrollHistory();
1598                  continue;
1599                } else if (!hasUnclosedThink && isThinking) {
1600                  isThinking = false;
1601                  var _thinkTextLen = _liveThinkInner ? _liveThinkInner.textContent.trim().length : 0;
1602
1603                  // If thinking was trivially short (< 20 chars), remove the section entirely
1604                  // Models sometimes emit <think>The</think> or similar noise
1605                  if (_thinkTextLen < 20 && _liveThinkSection) {
1606                    _liveThinkSection.remove();
1607                    _liveThinkSection = null;
1608                    _liveThinkContent = null;
1609                    _liveThinkInner = null;
1610                    _liveThinkHeader = null;
1611                    _liveThinkSpinnerSlot = null;
1612                    _liveThinkTimerEl = null;
1613                    _liveThinkToggle = null;
1614                    _liveThinkDomId = null;
1615                    // Fall through to normal streaming
1616                    if (spinner && spinner.element) spinner.destroy();
1617                    _renderStream();
1618                    _scheduleThinkingSpinner();
1619                    continue;
1620                  }
1621
1622                  // Thinking ended — smooth transition: update header, pause, then collapse
1623                  // Stop live timer and spinner
1624                  cancelAnimationFrame(_thinkTimerRAF);
1625                  var elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
1626                  // Embed thinking time in the <think> tag for persistence on reload
1627                  if (elapsed) {
1628                    accumulated = accumulated.replace(/<think>/i, '<think time="' + elapsed + '">');
1629                    roundText = roundText.replace(/<think>/i, '<think time="' + elapsed + '">');
1630                  }
1631                  if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
1632                  if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
1633                  // Move timer to right side of header
1634                  if (_liveThinkTimerEl && elapsed) {
1635                    _liveThinkTimerEl.textContent = elapsed + 's';
1636                    _liveThinkTimerEl.style.marginLeft = 'auto';
1637                    _liveThinkTimerEl.style.marginRight = '5px';
1638                    var _hdrRow = _liveThinkTimerEl.closest('.thinking-header');
1639                    // Chevron furthest right, timer to its left — insert before
1640                    // the toggle (appending would put the timer after it).
1641                    if (_hdrRow) {
1642                      if (_liveThinkToggle && _liveThinkToggle.parentElement === _hdrRow)
1643                        _hdrRow.insertBefore(_liveThinkTimerEl, _liveThinkToggle);
1644                      else _hdrRow.appendChild(_liveThinkTimerEl);
1645                    }
1646                  }
1647
1648                  // Assign stable IDs (for click-toggle handler in markdown.js)
1649                  var _thinkId = 'think-' + Date.now();
1650                  var _liveHdr = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header');
1651                  if (_liveHdr) _liveHdr.dataset.thinkingId = _thinkId;
1652                  if (_liveThinkContent) _liveThinkContent.id = _thinkId;
1653                  if (_liveThinkToggle) _liveThinkToggle.id = _thinkId + '-toggle';
1654
1655                  // Append a container for the reply text that follows thinking
1656                  var _streamEl = _liveThinkSection ? _liveThinkSection.parentElement : roundHolder.querySelector('.stream-content');
1657                  if (!_streamEl) _streamEl = roundHolder.querySelector('.body');
1658                  if (_streamEl) {
1659                    var _replyEl = document.createElement('div');
1660                    _replyEl.className = 'live-reply-content';
1661                    _streamEl.appendChild(_replyEl);
1662                  }
1663
1664                  // Render any reply text that arrived with the closing </think> token
1665                  _renderStream();
1666                } else {
1667                  // Normal streaming
1668                  if (spinner && spinner.element) spinner.destroy();
1669                  _renderStream();
1670                  _scheduleThinkingSpinner();
1671                  // Feed streaming TTS with accumulated text
1672                  if (streamingTTS) window.aiTTSManager.streamingUpdate(roundText);
1673                }
1674              } else if (json.type === 'research_progress') {
1675                if (_isBg) continue; // Skip DOM updates in background
1676                _researchingStreamIds.add(streamSessionId);
1677                // Highlight research button while running
1678                var _rToggle = document.getElementById('research-toggle-btn');
1679                if (_rToggle) _rToggle.classList.add('research-running');
1680                // Request notification permission on first research event
1681                if ('Notification' in window && Notification.permission === 'default') {
1682                  Notification.requestPermission();
1683                }
1684                // Mark session as researching in sidebar
1685                var _rSid = sessionModule && sessionModule.getCurrentSessionId();
1686                if (_rSid && sessionModule.markResearching) sessionModule.markResearching(_rSid);
1687                const rp = json.data;
1688                // Start research timer + synapse on first progress event
1689                if (!_researchTimerEl && spinner && spinner.element) {
1690                  _researchStartTime = rp.started_at ? rp.started_at * 1000 : Date.now();
1691                  _researchAvgDuration = rp.avg_duration || null;
1692                  _researchTimerEl = document.createElement('div');
1693                  _researchTimerEl.className = 'research-timer';
1694                  // Styles in .research-timer CSS class
1695                  spinner.element.parentNode.insertBefore(_researchTimerEl, spinner.element.nextSibling);
1696                  _researchTimerInterval = setInterval(() => {
1697                    if (!_researchTimerEl) return;
1698                    var elapsed = Math.floor((Date.now() - _researchStartTime) / 1000);
1699                    var mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
1700                    var ss = String(elapsed % 60).padStart(2, '0');
1701                    var txt = mm + ':' + ss;
1702                    if (_researchAvgDuration) {
1703                      var avgM = String(Math.floor(_researchAvgDuration / 60)).padStart(2, '0');
1704                      var avgS = String(Math.round(_researchAvgDuration % 60)).padStart(2, '0');
1705                      txt += ' / avg ' + avgM + ':' + avgS;
1706                    }
1707                    _researchTimerEl.textContent = txt;
1708                  }, 1000);
1709                  // Synapse visualization — insert right above the timer so
1710                  // it sits between the spinner message and the timer line.
1711                  try {
1712                    _researchSynapse = createResearchSynapse(spinner.element.parentNode, {
1713                      query: holder._researchQuery || rp.query || '',
1714                      startedAt: _researchStartTime,
1715                    });
1716                    // Move it to live between spinner and timer
1717                    if (_researchSynapse.element && _researchTimerEl) {
1718                      spinner.element.parentNode.insertBefore(_researchSynapse.element, _researchTimerEl);
1719                    }
1720                  } catch (e) { console.warn('synapse init failed', e); }
1721                }
1722                if (_researchSynapse) {
1723                  _researchSynapse.setPhase(rp.phase, rp);
1724                  if (typeof rp.round === 'number') _researchSynapse.setRound(rp.round);
1725                  if (typeof rp.total_sources === 'number') _researchSynapse.setSourceCount(rp.total_sources);
1726                  if (rp.phase === 'error') _researchSynapse.complete();
1727                }
1728                if (spinner && spinner.element) {
1729                  if (rp.phase === 'probing') {
1730                    spinner.updateMessage(`Verifying model: ${rp.model || '?'}`);
1731                  } else if (rp.phase === 'planning') {
1732                    spinner.updateMessage('Analyzing question & planning research strategy');
1733                  } else if (rp.phase === 'searching') {
1734                    const q = rp.queries ? `${rp.queries} queries` : '';
1735                    const s = rp.total_sources ? ` · ${rp.total_sources} sources` : '';
1736                    spinner.updateMessage(`Round ${rp.round || '?'}: Searching${q ? ' (' + q + ')' : ''}${s}`);
1737                  } else if (rp.phase === 'reading') {
1738                    spinner.updateMessage(rp.title ? `Reading: ${rp.title}` : `Round ${rp.round || '?'}: Reading ${rp.new_sources || ''} pages · ${rp.total_sources || 0} sources total`);
1739                  } else if (rp.phase === 'analyzing') {
1740                    spinner.updateMessage(`Round ${rp.round || '?'}: Analyzing ${rp.total_findings || 0} findings`);
1741                  } else if (rp.phase === 'writing') {
1742                    spinner.updateMessage(`Writing report · ${rp.total_sources || 0} sources`);
1743                  } else if (rp.phase === 'error') {
1744                    spinner.updateMessage(rp.message || 'Search error');
1745                  }
1746                }
1747              } else if (json.type === 'research_sources') {
1748                if (_isBg) {
1749                  // Store sources HTML in background map
1750                  if (json.data && json.data.length > 0) {
1751                    _sourcesHtml = _buildSourcesBox(json.data, 'research');
1752                    var bgE = _backgroundStreams.get(streamSessionId);
1753                    if (bgE) bgE.sourcesHtml = _sourcesHtml;
1754                  }
1755                  // Clear researching indicator for this background session
1756                  if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(streamSessionId);
1757                  continue;
1758                }
1759                // Research done — clean up timer, show sources box, then spinner for LLM response
1760                _clearResearchTimer();
1761                holder._researchSources = json.data;
1762                var _rSid2 = sessionModule && sessionModule.getCurrentSessionId();
1763                if (_rSid2 && sessionModule.clearResearching) sessionModule.clearResearching(_rSid2);
1764                if (json.data && json.data.length > 0) {
1765                  _sourcesData = json.data; _sourcesType = 'research';
1766                  _sourcesHtml = _buildSourcesBox(json.data, 'research');
1767                }
1768                if (document.hidden) {
1769                  _notifyResearchComplete(_rSid2 || '', holder._researchQuery || '');
1770                }
1771              } else if (json.type === 'research_findings') {
1772                if (_isBg) {
1773                  var bgEf = _backgroundStreams.get(streamSessionId);
1774                  if (bgEf) bgEf.findingsData = json.data;
1775                  continue;
1776                }
1777                if (json.data && json.data.length > 0) {
1778                  _findingsData = json.data;
1779                }
1780              } else if (json.type === 'research_done') {
1781                // Research complete — reload session to show the persisted report
1782                _clearResearchTimer();
1783                if (sessionModule && sessionModule.clearResearching) {
1784                  sessionModule.clearResearching(streamSessionId);
1785                }
1786                _researchingStreamIds.delete(streamSessionId);
1787                // Small delay then reload session history which includes the full report
1788                setTimeout(async () => {
1789                  // Don't yank the user back to this chat if they've navigated
1790                  // away (e.g. started a new chat) while research f
1790inished —
1791                  // just refresh the sidebar so the report shows when they return.
1792                  if (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() === streamSessionId) {
1793                    await sessionModule.selectSession(streamSessionId);
1794                  } else {
1795                    await sessionModule.loadSessions();
1796                  }
1797                }, 500);
1798                continue;
1799              } else if (json.type === 'web_sources') {
1800                if (_isBg) {
1801                  if (json.data && json.data.length > 0) {
1802                    _sourcesHtml = _buildSourcesBox(json.data, 'web');
1803                    var bgE2 = _backgroundStreams.get(streamSessionId);
1804                    if (bgE2) bgE2.sourcesHtml = _sourcesHtml;
1805                  }
1806                  continue;
1807                }
1808                // Web search done — store sources for final render (don't render mid-stream)
1809                holder._webSources = json.data;
1810                if (json.data && json.data.length > 0) {
1811                  _sourcesData = json.data; _sourcesType = 'web';
1812                  _sourcesHtml = _buildSourcesBox(json.data, 'web');
1813                }
1814              } else if (json.type === 'model_fallback') {
1815                // Model went offline — switched to fallback
1816                var _fbData = json.data || {};
1817                uiModule.showToast(
1818                  `Model ${_fbData.old_model || '?'} offline — switched to ${_fbData.new_model || '?'}`,
1819                  5000
1820                );
1821                // Update the model picker to reflect the new model
1822                if (sessionModule && sessionModule.updateModelPicker) {
1823                  sessionModule.updateModelPicker();
1824                }
1825                continue;
1826              } else if (json.type === 'model_info') {
1827                // Update role label with model name as soon as we know it
1828                if (!_isBg && holder) {
1829                  const roleEl = holder.querySelector('.role');
1830                  if (roleEl) {
1831                    holder._requestedModel = json.requested_model || json.model || holder._requestedModel;
1832                    holder._actualModel = json.model || holder._actualModel || holder._requestedModel;
1833                    if (json.suffix) holder._roleSuffix = json.suffix;
1834                    // Prepend character name if sent by server or set locally
1835                    var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
1836                    if (_charName) holder._characterName = _charName;
1837                    _setRoleModelLabel(roleEl, holder._requestedModel, holder._actualModel, {
1838                      suffix: holder._roleSuffix,
1839                      characterName: holder._characterName,
1840                    });
1841                  }
1842                }
1843              } else if (json.type === 'fallback') {
1844                // The selected model failed and another provider answered. Make
1845                // it visible so a misconfigured provider is never silently
1846                // masked under the selected model's name.
1847                if (!_isBg) {
1848                  var _selM = _shortModel(json.selected_model || '');
1849                  var _ansM = _shortModel(json.answered_by || '');
1850                  uiModule.showToast('⚠ ' + _selM + ' failed — answered by ' + _ansM, 6000);
1851                  if (holder) {
1852                    var _rEl = holder.querySelector('.role');
1853                    if (_rEl) {
1854                      var _tsS = _rEl.querySelector('.role-timestamp');
1855                      _rEl.textContent = _ansM + ' (fallback) ';
1856                      _rEl.title = (json.selected_model || '') + ' failed' +
1857                        (json.reason ? ': ' + json.reason : '') + ' — answered by ' + (json.answered_by || '');
1858                      _applyModelColor(_rEl, json.answered_by);
1859                      if (_tsS) _rEl.appendChild(_tsS);
1860                      holder._requestedModel = json.selected_model || holder._requestedModel || modelName;
1861                      const _hasResolvedActual = holder._actualModel && !_sameModelName(holder._actualModel, holder._requestedModel);
1862                      holder._actualModel = _hasResolvedActual ? holder._actualModel : (json.answered_by || holder._actualModel || holder._requestedModel);
1863                      _setRoleModelLabel(_rEl, holder._requestedModel, holder._actualModel, {
1864                        suffix: holder._roleSuffix,
1865                        characterName: holder._characterName,
1866                        reason: json.reason,
1867                      });
1868                    }
1869                  }
1870                }
1871              } else if (json.type === 'rounds_exhausted') {
1872                // The agent hit the per-turn step limit while still working.
1873                // Offer a Continue button instead of stalling silently.
1874                // NOTE: append to the chat-history container (bottom), NOT the
1875                // message body — the body innerHTML is re-rendered at stream
1876                // finalize, which would wipe a note placed inside it.
1877                const _chatBox = document.getElementById('chat-history');
1878                if (!_isBg && _chatBox) {
1879                  // Drop any prior box so repeated cap-hits each get a fresh
1880                  // Continue at the bottom (multiple continues in a row).
1881                  const _old = _chatBox.querySelector('.rounds-exhausted');
1882                  if (_old) _old.remove();
1883                  const note = document.createElement('div');
1884                  note.className = 'stopped-indicator rounds-exhausted';
1885                  const label = document.createElement('span');
1886                  label.className = 'rounds-exhausted-label';
1887                  label.textContent = `Reached the ${json.rounds || ''}-step limit — not finished.`;
1888                  note.appendChild(label);
1889                  const contBtn = document.createElement('button');
1890                  contBtn.className = 'continue-btn';
1891                  contBtn.title = 'Continue the task';
1892                  contBtn.textContent = 'Continue ▸';
1893                  const _holder = currentHolder;
1894                  contBtn.addEventListener('click', () => {
1895                    note.remove();
1896                    _hideUserBubble = true;
1897                    _pendingContinue = _holder;
1898                    const msgInput = uiModule.el('message');
1899                    if (msgInput) {
1900                      msgInput.value = 'You hit the step limit before finishing — the task is not complete. Continue from exactly where you left off and keep going until it is done. Do NOT repeat work already done.';
1901                      const sb = document.querySelector('.send-btn');
1902                      if (sb) sb.click();
1903                    }
1904                  });
1905                  note.appendChild(contBtn);
1906                  _chatBox.appendChild(note);
1907                  try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
1908                }
1909              } else if (json.type === 'model_actual') {
1910                if (!_isBg && holder) {
1911                  holder._requestedModel = json.requested_model || holder._requestedModel || modelName;
1912                  holder._actualModel = json.model || holder._actualModel || holder._requestedModel;
1913                  _setRoleModelLabel(holder.querySelector('.role'), holder._requestedModel, holder._actualModel, {
1914                    suffix: holder._roleSuffix,
1915                    characterName: holder._characterName,
1916                  });
1917                }
1918              } else if (json.type === 'attachments') {
1919                if (_isBg) continue;
1920                // Update user bubble — replace file chips with image previews
1921                const _ub = document.querySelector('#chat-history .msg-user:last-of-type');
1922                if (_ub) {
1923                  const _aw = _ub.querySelector('.attach-cards');
1924                  if (_aw) {
1925                    for (const _att of json.data) {
1926                      const _isImg = (_att.mime || '').startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(_att.name || '');
1927                      if (_isImg && _att.id) {
1928                        // Skip if we already have a preview for this file id —
1929                        // on a regenerate the original user bubble keeps its
1930                        // photo and the backend re-emits the attachment event
1931                        // for the same id; without this guard we'd append a
1932                        // duplicate (which visually pushes the real photo off).
1933                        const _existingPreview = _aw.querySelector('[data-file-id="' + _att.id + '"]');
1934                        if (_existingPreview) {
1935                          if (_att.vision_model && !_existingPreview.querySelector('.attach-vision-model')) {
1936                            const _vl = document.createElement('div');
1937                            _vl.className = 'attach-vision-model';
1938                            _vl.textContent = 'Vision: ' + String(_att.vision_model).split('/').pop();
1939                            const _name = _existingPreview.querySelector('.attach-image-name');
1940                            if (_name) _existingPreview.insertBefore(_vl, _name);
1941                            else _existingPreview.appendChild(_vl);
1942                          }
1943                          continue;
1944                        }
1945                        const _card = _aw.querySelector('.attach-card[data-name="' + (_att.name || '').replace(/"/g, '\\"') + '"]');
1946                        const _iw = document.createElement('div');
1947                        _iw.className = 'attach-image-preview';
1948                        _iw.dataset.fileId = _att.id;
1949                        _iw.style.cursor = 'pointer';
1950                        _iw.onclick = () => window.open(API_BASE + '/api/upload/' + _att.id, '_blank');
1951                        const _im = document.createElement('img');
1952                        _im.src = API_BASE + '/api/upload/' + _att.id;
1953                        _im.alt = _att.name || 'Image';
1954                        _im.style.cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:block;';
1955                        _iw.appendChild(_im);
1956                        if (_att.vision_model) {
1957                          const _vl = document.createElement('div');
1958                          _vl.className = 'attach-vision-model';
1959                          _vl.textContent = 'Vision: ' + String(_att.vision_model).split('/').pop();
1960                          _iw.appendChild(_vl);
1961                        }
1962                        if (_att.name) {
1963                          const _nm = document.createElement('div');
1964                          _nm.className = 'attach-image-name';
1965                          _nm.textContent = _att.name;
1966                          _iw.appendChild(_nm);
1967                        }
1968                        if (_card) _card.replaceWith(_iw); else _aw.appendChild(_iw);
1969                      } else {
1970                        const _card = _aw.querySelector('.attach-card[data-name="' + (_att.name || '').replace(/"/g, '\\"') + '"]');
1971                        if (_card && _att.id) {
1972                          _card.dataset.fileId = _att.id;
1973                          _card.style.cursor = 'pointer';
1974                          _card.onclick = () => window.open(API_BASE + '/api/upload/' + _att.id, '_blank');
1975                        }
1976                      }
1977                    }
1978                  }
1979                  // Caption / OCR text is no longer rendered as an inline
1980                  // collapsible on the user bubble — the user can view/edit
1981                  // it via the "Caption" button on the photo thumbnail.
1982                }
1983              } else if (json.type === 'rag_sources') {
1984                if (_isBg) continue;
1985                holder._ragSources = json.data;
1986              } else if (json.type === 'memories_used') {
1987                if (_isBg) continue;
1988                holder._memoriesUsed = json.data;
1989              } else if (json.type === 'compacted') {
1990                if (!_isBg) {
1991                  uiModule.showToast('Context compacted — older messages summarized');
1992                }
1993              } else if (json.type === 'metrics') {
1994                metrics = json.data;
1995                if (!_isBg && holder && metrics) {
1996                  holder._requestedModel = metrics.requested_model || holder._requestedModel || modelName;
1997                  holder._actualModel = metrics.model || holder._actualModel || holder._requestedModel;
1998                }
1999                if (_isBg) {
2000                  var bgM = _backgroundStreams.get(streamSessionId);
2001                  if (bgM) bgM.metrics = json.data;
2002                  continue;
2003                }
2004
2005              } else if (json.type === 'message_saved') {
2006                // Wire the persisted DB id onto the just-streamed bubble so it
2007                // can be edited/deleted immediately, without reloading the chat.
2008                if (_isBg) continue;
2009                if (currentHolder && json.id) currentHolder.dataset.dbId = json.id;
2010
2011              } else if (json.type === 'tool_start') {
2012                if (_isBg) continue;
2013                _cancelThinkingTimer();
2014                _removeThinkingSpinner();
2015                // Force-close thinking if still open — tools are real content, not thinking
2016                if (isThinking) {
2017                  isThinking = false;
2018                  cancelAnimationFrame(_thinkTimerRAF);
2019                  var _elapsed2 = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
2020                  if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
2021                  if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _elapsed2 + 's' : '';
2022                  if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
2023                  // Assign stable IDs
2024                  var _thinkId2 = 'think-' + Date.now();
2025                  var _liveHdr2 = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header');
2026                  if (_liveHdr2) _liveHdr2.dataset.thinkingId = _thinkId2;
2027                  if (_liveThinkContent) _liveThinkContent.id = _thinkId2;
2028                  if (_liveThinkToggle) _liveThinkToggle.id = _thinkId2 + '-toggle';
2029                }
2030                _renderStream();
2031                // --- Finalize current text bubble (only once per round) ---
2032                if (!roundFinalized) {
2033                  roundFinalized = true;
2034                  if (spinner && spinner.element) spinner.destroy();
2035                  const dt = stripToolBlocks(roundText);
2036                  if (dt.trim()) {
2037                    var _body3 = roundHolder.querySelector('.body');
2038                    var _contentEl3 = _ensureStreamLayout(_body3);
2039                    _contentEl3.style.minHeight = '';  // clear streaming inflate
2040                    _contentEl3.innerHTML = markdownModule.processWithThinking(markdownModule.squashOutsideCode(dt));
2041                    if (window.hljs) roundHolder.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b));
2042                  } else {
2043                    roundHolder.style.display = 'none';
2044                  }
2045                }
2046
2047                // Track tool name for contextual spinner labels
2048                _lastToolName = json.tool || '';
2049
2050                // --- Thread timeline: group tools in a thread container ---
2051                const cmd = json.command || '';
2052                const chatBox = document.getElementById('chat-history');
2053                // Find existing thread to append to — check last few children
2054                // (agent_step may insert an empty msg-ai between tool rounds)
2055                let threadWrap = null;
2056                for (let ci = chatBox.children.length - 1; ci >= Math.max(0, chatBox.children.length - 5); ci--) {
2057                  const child = chatBox.children[ci];
2058                  if (child.classList.contains('agent-thread')) {
2059                    threadWrap = child;
2060                    break;
2061                  }
2062                  // Skip hidden (empty) bubbles and thinking spinners
2063                  if (child.style.display === 'none' || child.classList.contains('agent-thinking-dots')) continue;
2064                  // Stop if we hit a visible message bubble (has real content between tools)
2065                  if (child.classList.contains('msg')) break;
2066                }
2067                if (threadWrap) {
2068                  // Continuing an existing thread — remove has-bottom (agent_step may have set it
2069                  // expecting text, but we got more tools instead)
2070                  threadWrap.classList.remove('has-bottom');
2071                } else {
2072                  threadWrap = document.createElement('div');
2073                  threadWrap.className = 'agent-thread';
2074                  // Extend line up to connect to chat bubble above (if there is one)
2075                  const _prevSib = chatBox.lastElementChild;
2076                  const _hasBubbleAbove = _prevSib && (_prevSib.classList.contains('msg') && _prevSib.style.display !== 'none');
2077                  const _hasThreadAbove = _prevSib && _prevSib.classList.contains('agent-thread');
2078                  if (_hasBubbleAbove || _hasThreadAbove || (rou
2078ndText.trim() && roundHolder && roundHolder.style.display !== 'none')) {
2079                    threadWrap.classList.add('has-top');
2080                  }
2081                  chatBox.appendChild(threadWrap);
2082                }
2083                threadWrap.classList.add('streaming');
2084                const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool;
2085                const node = document.createElement('div')
2086                node.className = 'agent-thread-node running';
2087                const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
2088                node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
2089                // Expand/collapse via delegated click handler (init at module bottom).
2090                threadWrap.appendChild(node);
2091                currentToolBubble = node;
2092                // Animate the wave
2093                const waveEl = node.querySelector('.agent-thread-wave');
2094                if (waveEl) {
2095                  const waveFrames = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
2096                  let waveIdx = 0;
2097                  node._waveInterval = setInterval(() => {
2098                    waveIdx = (waveIdx + 1) % waveFrames.length;
2099                    waveEl.textContent = waveFrames[waveIdx];
2100                  }, 100);
2101                }
2102                // Smooth per-second "cooking" timer — ticks every second (not
2103                // just on the 2s backend heartbeat) so a long-running tool
2104                // always shows visible motion and never reads as frozen.
2105                node._startTime = Date.now();
2106                node._elapsedTicker = setInterval(() => {
2107                  const hdr2 = node.querySelector('.agent-thread-header');
2108                  if (!hdr2) return;
2109                  let el2 = hdr2.querySelector('.agent-thread-elapsed');
2110                  if (!el2) {
2111                    el2 = document.createElement('span');
2112                    el2.className = 'agent-thread-elapsed';
2113                    // Sits on the LEFT, right after the icon.
2114                    const icon = hdr2.querySelector('.agent-thread-icon');
2115                    if (icon && icon.nextSibling) hdr2.insertBefore(el2, icon.nextSibling);
2116                    else hdr2.appendChild(el2);
2117                  }
2118                  const s = (Date.now() - node._startTime) / 1000;
2119                  // Hundredths so it visibly counts sub-second (1.00, 1.05, …).
2120                  el2.textContent = s < 60 ? `${s.toFixed(2)}s` : `${Math.floor(s / 60)}m ${(s % 60).toFixed(2).padStart(5, '0')}s`;
2121                }, 50);
2122                uiModule.scrollHistory();
2123
2124              } else if (json.type === 'tool_progress') {
2125                // Long-running subprocess (bash, python) is still in
2126                // flight — refresh the running tool card with the
2127                // elapsed-time + tail of its stdout/stderr so the
2128                // user doesn't stare at a blind "Running…" spinner.
2129                if (_isBg) continue;
2130                if (!currentToolBubble) continue;
2131                // The per-second ticker (started in tool_start) owns the
2132                // elapsed display; here we just surface the live output tail.
2133                const tailStr = (json.tail || '').trim();
2134                if (tailStr) {
2135                  let tailEl = currentToolBubble.querySelector('.agent-thread-tail');
2136                  if (!tailEl) {
2137                    tailEl = document.createElement('pre');
2138                    tailEl.className = 'agent-thread-tail';
2139                    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;';
2140                    const content = currentToolBubble.querySelector('.agent-thread-content');
2141                    if (content) content.appendChild(tailEl);
2142                  }
2143                  tailEl.textContent = tailStr;
2144                  tailEl.scrollTop = tailEl.scrollHeight;
2145                }
2146                uiModule.scrollHistory();
2147
2148              } else if (json.type === 'tool_output') {
2149                if (_isBg) continue;
2150                // --- Update the current thread node ---
2151                if (currentToolBubble) {
2152                  // Stop wave animation + the per-second cooking ticker
2153                  if (currentToolBubble._waveInterval) {
2154                    clearInterval(currentToolBubble._waveInterval);
2155                    currentToolBubble._waveInterval = null;
2156                  }
2157                  if (currentToolBubble._elapsedTicker) {
2158                    clearInterval(currentToolBubble._elapsedTicker);
2159                    currentToolBubble._elapsedTicker = null;
2160                  }
2161                  const ok = (json.exit_code === 0 || json.exit_code == null);
2162                  const cmd = json.command || '';
2163                  let outHtml = '';
2164                  if (json.output && json.output.trim()) {
2165                    outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(json.output)}</pre></details>`;
2166                  }
2167                  // File-write diff (write_file): show a before/after unified diff.
2168                  let diffHtml = '';
2169                  if (json.diff && json.diff.text) {
2170                    const d = json.diff;
2171                    // Collapsed summary: filename + +adds (green) / −dels (red).
2172                    const stat = [
2173                      d.new_file ? '<span class="diff-stat-new">new</span>' : '',
2174                      d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
2175                      d.removed ? `<span class="diff-stat-del">−${d.removed}</span>` : '',
2176                    ].filter(Boolean).join(' ');
2177                    const rows = d.text.split('\n').map(line => {
2178                      let cls = 'diff-ctx', text = line;
2179                      if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
2180                      else if (line.startsWith('@@')) cls = 'diff-hunk';
2181                      // Drop the leading diff marker (+/-/space) — the row colour
2182                      // already encodes add/del, and keeping it doubles up with
2183                      // markdown "- " bullets (reads as "+-"/"--").
2184                      else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
2185                      else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
2186                      else if (line.startsWith(' ')) { text = line.slice(1); }
2187                      return `<span class="${cls}">${esc(text) || '&nbsp;'}</span>`;
2188                    }).join('');  // spans are display:block — a literal \n here would double-space the diff
2189                    diffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
2190                  }
2191                  // For file edits the "command" is the raw JSON args — redundant
2192                  // next to the diff, so hide it when we have a diff to show.
2193                  const cmdHtml2 = (cmd && !(json.diff && json.diff.text)) ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
2194                  // Preserve the user's .open choice across the innerHTML
2195                  // rewrite \u2014 otherwise expanding a running tool collapses
2196                  // it as soon as the result lands, forcing the user to
2197                  // click again. Click handling is delegated (see init at
2198                  // bottom of file) so no per-node listener needed.
2199                  const _wasOpen = currentToolBubble.classList.contains('open');
2200                  currentToolBubble.className = 'agent-thread-node' + (ok ? '' : ' error') + (_wasOpen ? ' open' : '');
2201                  currentToolBubble.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(json.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml2}${outHtml}${diffHtml}</div>`;
2202                  // Reset so thinking spinner between tools says "Thinking" not the old tool's label
2203                  _lastToolName = '';
2204                  uiModule.scrollHistory();
2205                }
2206                // --- Render generated images inline ---
2207                if (json.image_url) {
2208                  const chatBox = document.getElementById('chat-history');
2209                  chatBox.appendChild(_buildImageBubble(json.image_url, json.image_prompt, json.image_model, json.image_size, json.image_quality, json.image_id));
2210                  uiModule.scrollHistory();
2211                  // Notify gallery to refresh if open
2212                  window.dispatchEvent(new CustomEvent('gallery-refresh'));
2213                }
2214                // --- Render browser screenshots in tool output ---
2215                if (json.screenshot && currentToolBubble) {
2216                  const contentEl = currentToolBubble.querySelector('.agent-thread-content');
2217                  if (contentEl) {
2218                    const screenshotSrc = chatRenderer.safeToolScreenshotSrc(json.screenshot);
2219                    if (screenshotSrc) {
2220                      const details = document.createElement('details');
2221                      details.className = 'agent-tool-output';
2222                      const summary = document.createElement('summary');
2223                      summary.textContent = 'Screenshot';
2224                      const img = document.createElement('img');
2225                      img.src = screenshotSrc;
2226                      img.style.cssText = 'max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)';
2227                      details.appendChild(summary);
2228                      details.appendChild(img);
2229                      contentEl.appendChild(details);
2230                    }
2231                  }
2232                }
2233                // --- Reload sessions after manage_session tool (delete, rename, etc.) ---
2234                // Debounce so bulk deletes don't fire loadSessions per call
2235                if (json.tool === 'manage_session' && sessionModule) {
2236                  if (window._manageSessionTimer) clearTimeout(window._manageSessionTimer);
2237                  window._manageSessionTimer = setTimeout(() => sessionModule.loadSessions(), 1000);
2238                }
2239                // --- Live-refresh the calendar after manage_calendar (add/edit/delete) ---
2240                // so a new event shows without the user hard-refreshing. Debounced
2241                // so a batch of event creates only triggers one refetch.
2242                if (json.tool === 'manage_calendar') {
2243                  if (window._manageCalTimer) clearTimeout(window._manageCalTimer);
2244                  window._manageCalTimer = setTimeout(
2245                    () => window.dispatchEvent(new CustomEvent('calendar-refresh')), 600);
2246                }
2247                // --- Live-refresh Memories after manage_memory changes ---
2248                if (json.tool === 'manage_memory') {
2249                  if (window._manageMemoryTimer) clearTimeout(window._manageMemoryTimer);
2250                  window._manageMemoryTimer = setTimeout(
2251                    () => window.dispatchEvent(new CustomEvent('memory-refresh')), 600);
2252                }
2253                // --- Apply UI control actions embedded in tool_output ---
2254                if (json.ui_event) {
2255                  chatStream.handleUIControl(json);
2256                }
2257
2258                // Schedule a thinking spinner between tool rounds (short delay so
2259                // agent_step in the same SSE chunk can cancel it before it shows)
2260                _scheduleThinkingSpinner();
2261                uiModule.scrollHistory();
2262
2263              } else if (json.type === 'doc_stream_open') {
2264                if (_isBg) {
2265                  // Store for replay when user returns to this session
2266                  var bgDocOpen = _backgroundStreams.get(streamSessionId);
2267                  if (bgDocOpen) {
2268                    bgDocOpen._docTitle = json.title || '';
2269                    bgDocOpen._docLang = json.language || '';
2270                    bgDocOpen._docContent = '';
2271                  }
2272                  continue;
2273                }
2274                if (documentModule) {
2275                  documentModule.streamDocOpen(json.title || '', json.language || '');
2276                }
2277
2278              } else if (json.type === 'doc_stream_delta') {
2279                if (_isBg) {
2280                  var bgDocDelta = _backgroundStreams.get(streamSessionId);
2281                  if (bgDocDelta) bgDocDelta._docContent = json.content || '';
2282                  continue;
2283                }
2284                if (documentModule) {
2285                  documentModule.streamDocDelta(json.content || '');
2286                }
2287
2288              } else if (json.type === 'doc_update') {
2289                // doc_update means the server already saved the doc to DB.
2290                if (_isBg) continue;
2291                if (documentModule) {
2292                  documentModule.handleDocUpdate(json);
2293                }
2294
2295              } else if (json.type === 'doc_suggestions') {
2296                if (_isBg) continue;
2297                if (documentModule && documentModule.handleDocSuggestions) {
2298                  documentModule.handleDocSuggestions(json);
2299                }
2300
2301              } else if (json.type === 'ui_control') {
2302                if (_isBg) continue;
2303                chatStream.handleUIControl(json.data || {});
2304
2305              } else if (json.type === 'ask_user') {
2306                if (_isBg) continue;
2307                // The agent posed a multiple-choice question; the turn has ended.
2308                // Render clickable options at the bottom of the history. The
2309                // user's pick is sent as the next message and the agent resumes.
2310                _cancelThinkingTimer();
2311                _removeThinkingSpinner();
2312                const _aq = json.data || {};
2313                const _opts = Array.isArray(_aq.options) ? _aq.options : [];
2314                if (_aq.question && _opts.length) {
2315                  const chatBox = document.getElementById('chat-history');
2316                  // Drop any prior unanswered card so only the latest shows.
2317                  chatBox.querySelectorAll('.ask-user-card').forEach(n => n.remove());
2318                  const card = document.createElement('div');
2319                  card.className = 'ask-user-card';
2320                  const multi = !!_aq.multi;
2321                  // Group the choices for assistive tech and label the group with
2322                  // the question (set below); make the card focusable so it can be
2323                  // moved to when it appears.
2324                  card.setAttribute('role', 'group');
2325                  card.tabIndex = -1;
2326                  // Render any emoji in agent-supplied text through the app's
2327                  // pipeline: escape, then svgify to monochrome theme-tinted
2328                  // glyphs (project rule: never colorful emoji; respects the
2329                  // "Text-only Emojis" setting like the rest of the chat).
2330                  const _emo = (s) => svgifyEmoji(uiModule.esc(String(s)));
2331
2332                  // Header row holds the close (×) to dismiss the affordances and
2333                  // just type a reply instead.
2334                  const head = document.createElement('div');
2335                  head.className = 'ask-user-head';
2336                  const closeBtn = document.createElement('button');
2337                  closeBtn.type = 'button';
2338                  closeBtn.className = 'modal-close ask-user-close';
2339                  closeBtn.setAttribute('aria-label', 'Dismiss question');
2340                  closeBtn.textContent = '×';
2341                  closeBtn.addEventListener('click', () => {
2342                    card.remove();
2343                    const mi = uiModule.el('message');
2344                    if (mi) mi.focus();
2345                  });
2346                  head.appendChild(closeBtn);
2347                  card.appendChild(head);
2348
2349                  // Render the question inside the card so it's self-contained:
2350                  // some models call ask_user without first narrating the question
2351                  // as assistant text, in which case the card would otherwise show
2352                  // bare options with no prompt.
2353                  if (_aq.question) {
2354                    const q = document.createElement('div');
2355                    q.className = 'ask-user-question';
2356                    q.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
2357                    q.innerHTML = _emo(_aq.question);
2358                    card.appendChild(q);
2359                    // Label the choice group with the question for screen readers.
2360                    card.setAttribute('aria-labelledby', q.id);
2361                  } else {
2362                    card.setAttribute('aria-label', 'Question from the assistant');
2363                  }
2364
2365                  const list = document.createElement('div');
2366                  list.className = 'ask-user-options';
2367                  card.appendChild(list);
2368
2369                  const _send = (text) => {
2370                    if (!text) return;
2371                    // Remove the card once answered — the choice is sent as a
2372                    // normal user message (and the question persists as the
2373                    // assistant text above), so the affordances are spent.
2374                    card.remove();
2375                    const mi = uiModule.el('message');
2376                    if (mi) mi.value = text;
2377                    const sb = document.querySelector('.send-btn');
2378                    if (sb) sb.click();
2379                  };
2380
2381                  _opts.forEach((opt, i) => {
2382                    const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
2383                    if (!label) return;
2384                    const descr = (opt && opt.description) ? String(opt.description) : '';
2385                    const row = document.createElement(multi ? 'label' : 'button');
2386                    row.className = 'ask-user-option';
2387                    if (multi) {
2388                      const cb = document.createElement('input');
2389                      cb.type = 'checkbox';
2390                      cb.value = label;
2391                      row.appendChild(cb);
2392                    }
2393                    const txt = document.createElement('span');
2394                    txt.className = 'ask-user-option-label';
2395                    txt.innerHTML = _emo(label);
2396                    row.appendChild(txt);
2397                    if (descr) {
2398                      const d = document.createElement('span');
2399                      d.className = 'ask-user-option-desc';
2400                      d.innerHTML = _emo(descr);
2401                      row.appendChild(d);
2402                    }
2403                    if (!multi) {
2404                      row.type = 'button';
2405                      row.addEventListener('click', () => _send(label));
2406                    }
2407                    list.appendChild(row);
2408                  });
2409
2410                  // Free-text "Other" — type a custom answer + send (Enter or →).
2411                  const other = document.createElement('div');
2412                  other.className = 'ask-user-other';
2413                  const otherInput = document.createElement('input');
2414                  otherInput.type = 'text';
2415                  otherInput.className = 'styled-prompt-input ask-user-other-input';
2416                  otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
2417                  otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
2418                  const otherSend = document.createElement('button');
2419                  otherSend.type = 'button';
2420                  otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
2421                  otherSend.setAttribute('aria-label', 'Send answer');
2422                  otherSend.textContent = multi ? 'Send selection' : 'Send';
2423                  const _submit = () => {
2424                    const free = otherInput.value.trim();
2425                    if (multi) {
2426                      const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map(c => c.value);
2427                      if (free) picked.push(free);
2428                      if (picked.length) _send(picked.join(', '));
2429                    } else if (free) {
2430                      _send(free);
2431                    }
2432                  };
2433                  otherSend.addEventListener('click', _submit);
2434                  otherInput.addEventListener('keydown', (e) => {
2435                    if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
2436                      e.preventDefault();
2437                      _submit();
2438                    }
2439                  });
2440                  other.appendChild(otherInput);
2441                  other.appendChild(otherSend);
2442                  card.appendChild(other);
2443
2444                  chatBox.appendChild(card);
2445                  card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2446                  // Move focus to the card so keyboard/screen-reader users land on
2447                  // the question + choices when it appears.
2448                  try { card.focus(); } catch (_) {}
2449                }
2450
2451              } else if (json.type === 'plan_update') {
2452                if (_isBg) continue;
2453                // Agent wrote back to the plan (ticked a step / revised). Update
2454                // the stored plan + live-refresh the docked plan window.
2455                const _pu = (json.data && json.data.plan) ? json.data.plan : '';
2456                if (_pu) _setStoredPlan(_pu);
2457
2458              } else if (json.type === 'agent_step') {
2459                if (_isBg) continue;
2460                _cancelThinkingTimer();
2461                _removeThinkingSpinner();
2462                _renderStream();
2463                // Mark thread as connected to bubble below
2464                const _activeThread = document.querySelector('.agent-thread.streaming');
2465                if (_activeThread) {
2466                  _activeThread.classList.add('has-bottom');
2467                }
2468                // --- New round: create fresh AI bubble with spinner ---
2469                currentToolBubble = null;
2470                roundFinalized = false;
2471                isThinking = false;
2472                _docFenceOpened = false;
2473                _docFenceContentStart = -1;
2474                const box = document.getElementById('chat-history');
2475                const newWrap = document.createElement('div');
2476                newWrap.className = 'msg msg-ai msg-continuation streaming';
2477                // Add model name label
2478                const newRole = document.createElement('div');
2479                newRole.className = 'role';
2480                const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
2481                const _roundRequested = holder?._requestedModel || metaS?.model;
2482                const _roundActual = holder?._actualModel || _roundRequested;
2483                newRole.textContent = _modelRouteLabel(_roundRequested, _roundActual) || '';
2484                _applyModelColor(newRole, _roundActual);
2485                newWrap.appendChild(newRole);
2486                const newBody = document.createElement('div');
2487                newBody.className = 'body';
2488                newWrap.appendChild(newBody);
2489                box.appendChild(newWrap);
2490                roundHolder = newWrap;
2491                roundText = '';
2492                // Destroy any previous spinner before creating new one
2493                if (spinner && spinner.element) spinner.destroy();
2494                // Show spinner while waiting for text (skip for research — has its own progress)
2495                if (!_researchingStreamIds.has(streamSessionId)) {
2496                  spinner = spinnerModule.create('Generating response', 'right', 'wave');
2497                  newBody.appendChild(spinner.createElement());
2498                  spinner.start();
2499                }
2500                if (streamingTTS) window.aiTTSManager._streamSentencesSent = 0;
2501                uiModule.scrollHistory();
2502              } else if (json.type === 'budget_exceeded') {
2503                if (_isBg) continue;
2504                _cancelThinkingTimer();
2505                _removeThinkingSpinner();
2506                const budgetDiv = document.createElement('div');
2507                budgetDiv.style.cssText = 'font-size:11px;opacity:0.6;font-style:italic;padding:4px 8px;margin:4px 0;';
2508                budgetDiv.textContent = `Tool budget reached (${json.used}/${json.limit} calls). Agent stopped.`;
2509                const chatBox = document.getElementById('chat-history');
2510                chatBox.appendChild(budgetDiv);
2511
2512              } else if (json.type === 'teacher_takeover') {
2513                if (_isBg) continue;
2514                _cancelThinkingTimer();
2515                _removeThinkingSpinner();
2516                // Finalize any in-flight bubble so the takeover banner
2517                // separates student attempt from teacher attempt.
2518                if (spinner && spinner.element) { try { spinner.destroy(); } catch(_){} spinner = null; }
2519                const chatBox = document.getElementById('chat-history');
2520                const banner = document.createElement('div');
2521                banner.className = 'teacher-takeover-banner';
2522                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;';
2523                const teacherName = json.teacher_model || 'teacher';
2524                const why = json.student_failure ? ` &mdash; <span style="opacity:0.7">${esc(json.student_failure)}</span>` : '';
2525                banner.innerHTML = `<strong>Teacher takeover:</strong> escalating to <code>${esc(teacherName)}</code>${why}`;
2526                chatBox.appendChild(banner);
2527                // Reset round bubble state so the teacher's first text starts a new bubble
2528                roundHolder = null;
2529                roundText = '';
2530                roundFinalized = false;
2531                currentToolBubble = null;
2532                uiModule.scrollHistory();
2533
2534              } else if (json.type === 'skill_saved') {
2535                if (_isBg) continue;
2536                const chatBox = document.getElementById('chat-history');
2537                const note = document.createElement('div');
2538                note.className = 'skill-saved-note';
2539                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;';
2540                note.innerHTML = `<strong>Skill learned:</strong> <code>${esc(json.name || '')}</code>${json.category ? ` <span style="opacity:0.6">[${esc(json.category)}]</span>` : ''}`;
2541                chatBox.appendChild(note);
2542                uiModule.scrollHistory();
2543
2544              } else if (json.type === 'escalation_failed' || json.type === 'skill_save_failed') {
2545                if (_isBg) continue;
2546                const chatBox = document.getElementById('chat-history');
2547                const note = document.createElement('div');
2548                note.className = 'escalation-failed-note';
2549                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;';
2550                const label = json.type === 'escalation_failed' ? 'Teacher could not solve it' : 'Skill not saved';
2551                note.innerHTML = `<strong>${label}:</strong> <span style="opacity:0.75">${esc(json.reason || '')}</span>`;
2552                chatBox.appendChild(note);
2553                uiModule.scrollHistory();
2554
2555              } else if (json.error) {
2556                // --- Backend error (timeout, connection issue, etc.) ---
2557                console.error('Stream error from backend:', json.error);
2558                if (_isBg) continue;
2559                if (spinner && spinner.element) spinner.destroy();
2560                const errDiv = document.createElement('div');
2561                errDiv.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;';
2562                errDiv.textContent = `[Error: ${json.error}]`;
2563                roundHolder.querySelector('.body').appendChild(errDiv);
2564                uiModule.scrollHistory();
2565              }
2566            } catch (e) {
2567              console.error('Error parsing SSE data:', e);
2568            }
2569          }
2570        }
2571      }
2572
2573      if (!_streamSawDone) {
2574        throw new Error('Stream closed before completion');
2575      }
2576
2577      _renderStream();
2578      _cancelThinkingTimer();
2579      _removeThinkingSpinner();
2580      // Stop any thread pulse animations
2581      document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming'));
2582      // --- Final render (skip if stream was ever backgrounded or currently in background) ---
2583      // Remove streaming class from all round bubbles
2584      holder.classList.remove('streaming');
2585      if (roundHolder && roundHolder !== holder) roundHolder.classList.remove('streaming');
2586
2587      const _isBgFinal = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
2588      if (!_isBgFinal) {
2589        finalMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
2590        const _finalActualModel = metrics?.model || holder._actualModel || finalMeta?.model;
2591        const _finalRequestedModel = metrics?.requested_model || holder._requestedModel || finalMeta?.model || _finalActualModel;
2592        // Prepend character name if set
2593        var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
2594        const roleEl = holder.querySelector('.role');
2595        if (roleEl) {
2596          _setRoleModelLabel(roleEl, _finalRequestedModel, _finalActualModel, {
2597            suffix: holder._roleSuffix,
2598            characterName: _charNameFinal || holder._characterName,
2599          });
2600        }
2601        holder.dataset.raw = accumulated;
2602
2603        // Anti-stall: a turn that ran tools but ended with essentially no
2604        // final prose usually means the model stopped mid-task (the case
2605        // where you had to type "did you finish?"). Offer a one-click
2606        // Continue that resumes exactly where it left off — reuses the same
2607        // resume mechanism as the user-stop "[Message interrupted]" button.
2608        try {
2609          const _usedTools = holder.querySelector('.agent-thread-node');
2610          const _proseLen = (accumulated || '').replace(/<[^>]*>/g, '').trim().length;
2611          if (_usedTools && _proseLen < 24 && !holder.querySelector('.agent-continue-btn')) {
2612            const _stall = document.createElement('div');
2613            _stall.className = 'stopped-indicator';
2614            const _lbl = document.createElement('span');
2615            _lbl.style.cssText = 'font-style:italic;opacity:0.7;';
2616            _lbl.textContent = 'Paused mid-task';
2617            _stall.appendChild(_lbl);
2618            const _cont = document.createElement('button');
2619            _cont.className = 'continue-btn agent-continue-btn';
2620            _cont.title = 'Continue — pick up where it left off';
2621            _cont.textContent = '▸';
2622            _cont.addEventListener('click', () => {
2623              _stall.remove();
2624              const mi = uiModule.el('message');
2625              if (mi) {
2626                mi.value = 'Continue — you stopped before finishing. Pick up exactly where you left off and complete the task.';
2627                const sb = document.querySelector('.send-btn');
2628                if (sb) sb.click();
2629              }
2630            });
2631            _stall.appendChild(_cont);
2632            (holder.querySelector('.body') || holder).appendChild(_stall);
2633          }
2634        } catch (_) {}
2635
2636        // Clear streaming minHeight lock
2637        const _streamContent = roundHolder.querySelector('.stream-content');
2638        if (_streamContent) _streamContent.style.minHeight = '';
2639
2640        // Finalize the last round's bubble — flatten stream-content wrapper for clean DOM
2641        const finalDisplay = stripToolBlocks(roundText);
2642        if (finalDisplay.trim()) {
2643          var _body4 = roundHolder.querySelector('.body');
2644          // Preserve sources expanded state before final render
2645          var _wasExpanded = _sourcesExpanded || !!(_body4 && _body4.querySelector('.sources-content.expanded'));
2646
2647          // If thinking was collapsed in-place during streaming, preserve it
2648          var _liveReplyEl = _body4 && _body4.querySelector('.live-reply-content');
2649          var _extracted = _liveReplyEl ? markdownModule.extractThinkingBlocks(finalDisplay) : null;
2650          var _finalReply = '';
2651          if (_liveReplyEl) {
2652            // Try standard extraction first (for native <think> tags)
2653            if (_extracted?.thinkingBlocks?.length) {
2654              _finalReply = (_extracted.content || '').trim();
2655            } else {
2656              // Non-tag thinking: extract reply from raw text
2657              // Handle garbled thinking tag: "Thinking: reasoning\n<think>reply"
2658              const _garbledMatch = finalDisplay.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
2659              if (_garbledMatch && _garbledMatch[1].trim()) {
2660                _finalReply = _garbledMatch[1].trim();
2661              } else {
2662                // Pure non-tag: find reply boundary by prefix patterns
2663                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"];
2664                const _fr = (finalDisplay || '').trimStart();
2665                if (markdownModule.startsWithReasoningPrefix(_fr)) {
2666                  const _fLines = _fr.split('\n');
2667                  for (let _fi = 1; _fi < _fLines.length; _fi++) {
2668                    const _fl = _fLines[_fi].trim();
2669                    if (!_fl) continue;
2670                    if (_rs2.some(rp => _fl.startsWith(rp))) { _finalReply = _fLines.slice(_fi).join('\n'); break; }
2671                  }
2672                  // Within-line check
2673                  if (!_finalReply) {
2674                    for (const rp of _rs2) {
2675                      const rx = new RegExp('[.!?]\\s*(' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')');
2676                      const m = rx.exec(_fr);
2677                      if (m && m.index > 20) { _finalReply = _fr.slice(m.index + 1).trim(); break; }
2678                    }
2679                  }
2680                }
2681              }
2682            }
2683          }
2684          if (_liveReplyEl && _finalReply) {
2685            // Render reply into the live-reply container (thinking bar already showing)
2686            var _replyHtml = markdownModule.mdToHtml(markdownModule.squashOutsideCode(_finalReply));
2687            _liveReplyEl.innerHTML = _replyHtml;
2688            _liveReplyEl.classList.remove('live-reply-content');
2689            if (_sourcesData) {
2690              var _srcEl = document.createElement('div');
2691              _srcEl.innerHTML = _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded);
2692              _body4.insertBefore(_srcEl.firstChild || _srcEl, _body4.firstChild);
2693            }
2694            if (_findingsData) _body4.insertAdjacentHTML('beforeend', chatRenderer.buildFindingsBox(_findingsData));
2695          } else {
2696            // Full re-render (reply empty or no live-reply container)
2697            _body4.innerHTML = (_sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded) : '')
2698              + markdownModule.processWithThinking(markdownModule.squashOutsideCode(finalDisplay))
2699              + (_findingsData ? chatRenderer.buildFindingsBox(_findingsData) : '');
2700          }
2701        } else if (_sourcesHtml) {
2702          var _body4b = roundHolder.querySelector('.body');
2703          var _wasExpanded2 = _sourcesExpanded || !!(_body4b && _body4b.querySelector('.sources-content.expanded'));
2704          _body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml;
2705        } else if (roundHolder !== holder) {
2706          // Check if there's thinking content worth showing
2707          const _thinkingOnly = markdownModule.extractThinkingBlocks(roundText);
2708          if (_thinkingOnly.thinkingBlocks?.length && !_thinkingOnly.content) {
2709            // Show thinking in a collapsed section even if no visible reply text
2710            const _body4c = roundHolder.querySelector('.body');
2711            if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText);
2712          } else {
2713            roundHolder.style.display = 'none';
2714            // Thread above expected a bubble below — remove has-bottom since bubble is hidden
2715            const _lastThread = roundHolder.previousElementSibling;
2716            if (_lastThread && _lastThread.classList.contains('agent-thread')) {
2717              _lastThread.classList.remove('has-bottom');
2718            }
2719          }
2720        }
2721
2722
2723        if (window.hljs) {
2724          roundHolder.querySelectorAll('pre code').forEach((block) => {
2725            window.hljs.highlightElement(block);
2726          });
2727        }
2728        if (markdownModule.renderMermaid) markdownModule.renderMermaid(roundHolder);
2729
2730        uiModule.scrollHistory();
2731        // Render RAG sources if present
2732        if (holder._ragSources && holder._ragSources.length) {
2733          const details = document.createElement('details');
2734          details.className = 'rag-sources';
2735          const summary = document.createElement('summary');
2736          summary.textContent = `Sources (${holder._ragSources.length} documents)`;
2737          details.appendChild(summary);
2738          holder._ragSources.forEach(src => {
2739            const item = document.createElement('div');
2740            item.className = 'rag-source-item';
2741            const _esc = uiModule.esc;
2742            item.innerHTML = `<strong>${_esc(src.filename)}</strong> <span class="rag-similarity">${(src.similarity * 100).toFixed(1)}%</span><div class="rag-snippet">${_esc(src.snippet)}</div>`;
2743            details.appendChild(item);
2744          });
2745          holder.querySelector('.body').appendChild(details);
2746        }
2747
2748        // Hide first bubble if it has no visible text content (e.g. agent went straight to tools)
2749        if (holder !== roundHolder && holder.style.display !== 'none') {
2750          const _hBody = holder.querySelector('.body');
2751          const _hText = _hBody ? _hBody.textContent.trim() : '';
2752          if (!_hText) holder.style.display = 'none';
2753        }
2754
2755        // Attach footer to the last visible bubble (roundHolder for multi-round agent, holder for single)
2756        const footerTarget = (roundHolder && roundHolder !== holder && roundHolder.style.display !== 'none') ? roundHolder : holder;
2757        footerTarget.appendChild(createMsgFooter(footerTarget));
2758        // Capture any checklist this message produced as the current plan — both
2759        // the initial proposal AND restated progress during execution. Keeps the
2760        // stored plan (and the docked plan window) in sync with the latest state.
2761        if (accumulated && _CHECKLIST_RE.test(accumulated)) {
2762          _setStoredPlan(accumulated);
2763        }
2764        // Plan mode: the agent has proposed a plan — offer to approve & execute it.
2765        // Approving re-sends with plan_mode suppressed (full tools) for one turn.
2766        if (planTurn && accumulated.trim()) {
2767          const _planText = accumulated;
2768          const _runApproved = () => {
2769            _approveWrap.remove();
2770            _forcePlanOff = true;
2771            // Persist the approved plan for THIS chat so it's (a) re-sent and
2772            // pinned in context every execution turn, and (b) re-openable via the
2773            // plan-button menu. Do this BEFORE flipping the toggle, since the menu
2774            // intercept keys off a stored plan existing.
2775            _setStoredPlan(_planText);
2776            // Approving exits plan mode for good — turn it OFF directly (NOT via
2777            // the button's click, which would now open the plan menu instead of
2778            // toggling) so execution and every follow-up keep full write tools.
2779            try { if (window._setPlanMode) window._setPlanMode(false); } catch (_) {}
2780            const _inp = el('message');
2781            if (_inp) {
2782              _inp.value = 'Approved — execute the plan. The full approved checklist is pinned '
2783                + 'for you under "## ACTIVE PLAN"; do NOT go looking for it in tasks, notes, or '
2784                + 'memory. Work through it in order, and after each step call the update_plan tool '
2785                + 'with the full checklist and that step marked `- [x]`. Do the next unchecked item '
2786                + 'until all are done.';
2787              _inp.dispatchEvent(new Event('input'));
2788            }
2789            // Show a clean bubble; the full instruction still goes to the model.
2790            _displayOverride = 'Approved the plan.';
2791            handleChatSubmit({ preventDefault() {} });
2792          };
2793          var _approveWrap = document.createElement('div');
2794          _approveWrap.className = 'plan-approve-bar';
2795          const _approveBtn = document.createElement('button');
2796          _approveBtn.type = 'button';
2797          _approveBtn.className = 'plan-approve-btn';
2798          _approveBtn.textContent = 'Approve & Run';
2799          _approveBtn.addEventListener('click', _runApproved);
2800          // Open the plan in a draggable, side-dockable window (reuses the
2801          // shared modal framework). Approving from the window runs it too.
2802          const _openBtn = document.createElement('button');
2803          _openBtn.type = 'button';
2804          _openBtn.className = 'plan-open-btn';
2805          _openBtn.textContent = 'Open in window';
2806          _openBtn.addEventListener('click', () => {
2807            planWindowModule.openPlanWindow(_planText, _runApproved);
2808          });
2809          _approveWrap.appendChild(_approveBtn);
2810          _approveWrap.appendChild(_openBtn);
2811          footerTarget.appendChild(_approveWrap);
2812        }
2813        // Add "View Report" link for completed research
2814        if (_researchingStreamIds.has(streamSessionId)) {
2815          _appendViewReportLink(footerTarget, streamSessionId);
2816        }
2817        // Also store raw on the footer target so copy/TTS work
2818        if (footerTarget !== holder) footerTarget.dataset.raw = accumulated;
2819        if (addAITTSButton && accumulated && window.aiTTSManager?._provider !== 'disabled' && window.aiTTSManager?.available) {
2820          addAITTSButton(footerTarget, accumulated);
2821        }
2822        // TTS auto-play: streaming mode flushes remaining text, non-streaming enqueues full message
2823        if (accumulated && window.aiTTSManager && window.aiTTSManager.autoPlay) {
2824          const ttsBtn = holder.querySelector('.ai-tts-button');
2825          if (ttsBtn) {
2826            var ICON_PLAY_TTS = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg>';
2827            var ICON_STOP_TTS = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>';
2828            const resetFn = () => {
2829              ttsBtn.innerHTML = ICON_PLAY_TTS;
2830              ttsBtn.classList.remove('playing', 'loading');
2831              ttsBtn.style.color = '#6b7280';
2832              ttsBtn.title = 'Read aloud';
2833            };
2834            if (streamingTTS) {
2835              // Flush remaining partial sentence and attach the real button
2836              window.aiTTSManager.streamingEnd(accumulated);
2837              window.aiTTSManager.streamingAttachButton(ttsBtn, resetFn);
2838              // If still playing sentences from the stream, show stop icon
2839              if (window.aiTTSManager.isPlaying || window.aiTTSManager._processing) {
2840                ttsBtn.innerHTML = ICON_STOP_TTS;
2841                ttsBtn.classList.add('playing');
2842                ttsBtn.style.color = '#ccc';
2843                ttsBtn.title = 'Stop';
2844              }
2845            } else {
2846              // Non-streaming fallback (autoPlay toggled mid-stream, etc.)
2847              window.aiTTSManager.enqueue(accumulated, ttsBtn, resetFn);
2848            }
2849          }
2850        }
2851        if (metrics) {
2852          displayMetrics(footerTarget, metrics);
2853        }
2854        // Attach variant navigation if this was a regeneration
2855        _attachVariantNav(footerTarget);
2856
2857        // Merge with previous stopped message if this was a continue
2858        if (_pendingContinue) {
2859          const prevEl = _pendingContinue;
2860          _pendingContinue = null;
2861          const prevBody = prevEl.querySelector('.body');
2862          const newBody = footerTarget.querySelector('.body');
2863          if (prevBody && newBody && prevEl.parentNode) {
2864            // Merge: combine raw text with *(continued)* marker
2865            const oldRaw = prevEl.dataset.raw || '';
2866            const newRaw = footerTarget.dataset.raw || '';
2867            const mergedRaw = oldRaw + '\n\n*(continued)*\n\n' + newRaw;
2868            prevEl.dataset.raw = mergedRaw;
2869            // Re-render merged content
2870            prevBody.innerHTML = markdownModule.processWithThinking(
2871              markdownModule.squashOutsideCode(mergedRaw)
2872            );
2873            // Remove the new bubble and re-add footer to the merged one
2874            footerTarget.remove();
2875            const oldFooter = prevEl.querySelector('.msg-footer');
2876            if (oldFooter) oldFooter.remove();
2877            prevEl.appendChild(createMsgFooter(prevEl));
2878            if (window.hljs) {
2879              prevEl.querySelectorAll('pre code').forEach(block => window.hljs.highlightElement(block));
2880            }
2881
2882            // Persist merge to server
2883            const sid = sessionModule.getCurrentSessionId();
2884            if (sid) {
2885              fetch(`${API_BASE}/api/session/${sid}/merge-last-assistant`, {
2886                method: 'POST',
2887                headers: { 'Content-Type': 'application/json' },
2888                body: JSON.stringify({ separator: '\n\n*(continued)*\n\n' })
2889              }).catch(e => console.warn('merge-last-assistant failed:', e));
2890            }
2891          }
2892        }
2893      } // end if (!_isBgFinal)
2894
2895    } catch (err) {
2896      _renderStream();
2897      // Clean up any active spinner (e.g. "Generating response" during tool calls)
2898      if (spinner && spinner.element) spinner.destroy();
2899      _cancelThinkingTimer();
2900      _removeThinkingSpinner();
2901      document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming'));
2902      // Check if this stream was running in background
2903      const _isBgCatch = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
2904
2905      if (_isBgCatch) {
2906        // Error happened while backgrounded — update map, don't touch DOM
2907        console.error('Background stream error:', err);
2908        var bgErr = _backgroundStreams.get(streamSessionId);
2909        if (bgErr && bgErr.status === 'completed') {
2910          // [DONE] was already processed — this error is benign (e.g. reader.read() after close)
2911          // Don't override the completed status; just ensure the completed dot stays
2912          if (sessionModule && sessionModule.clearStreaming) {
2913            sessionModule.clearStreaming(streamSessionId);
2914          }
2915        } else if (bgErr) {
2916          bgErr.status = 'error';
2917          if (sessionModule && sessionModule.clearStreaming) {
2918            sessionModule.clearStreaming(streamSessionId);
2919          }
2920        }
2921      } else {
2922        // Stop streaming TTS on any error/abort
2923        if (streamingTTS && window.aiTTSManager) window.aiTTSManager.stop();
2924
2925        if (currentAbort && currentAbort.signal.aborted) {
2926          const abortReason = currentAbort._reason || '';
2927          // Timeout-triggered aborts should remain visible instead of disappearing.
2928          if (timedOut || abortReason === 'timeout') {
2929            const timeoutMsg = _isAgent
2930              ? 'Agent response timed out. Try again, switch to a faster model, or reduce tool usage.'
2931              : 'Response timed out. Try again.';
2932
2933            if (holder && !accumulated) {
2934              holder.querySelector('.body').innerHTML =
2935                `<div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[${timeoutMsg}]</div>`;
2936            } else if (holder && accumulated) {
2937              const timeoutNote = document.createElement('div');
2938              timeoutNote.className = 'stopped-indicator';
2939              timeoutNote.innerHTML =
2940                `<span style="color: var(--color-error);">[${timeoutMsg}]</span>`;
2941              holder.querySelector('.body').appendChild(timeoutNote);
2942            }
2943            currentAbort = null;
2944            return;
2945          }
2946
2947          if (abortReason === 'offline') {
2948            const offlineMsg = 'Endpoint offline — switch model or try again.';
2949            if (holder && !accumulated) {
2950              holder.querySelector('.body').innerHTML =
2951                `<div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[${offlineMsg}]</div>`;
2952            } else if (holder && accumulated) {
2953              const offlineNote = document.createElement('div');
2954              offlineNote.className = 'stopped-indicator';
2955              offlineNote.innerHTML =
2956                `<span style="color: var(--color-error);">[${offlineMsg}]</span>`;
2957              holder.querySelector('.body').appendChild(offlineNote);
2958            }
2959            currentAbort = null;
2960            return;
2961          }
2962
2963          if (abortReason === 'recovery') {
2964            const recoveryMsg = 'Streaming was interrupted after the tab went inactive. Partial output was preserved.';
2965            if (holder && !accumulated) {
2966              holder.querySelector('.body').innerHTML =
2967                `<div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[${recoveryMsg}]</div>`;
2968            } else if (holder && accumulated) {
2969              const recoveryNote = document.createElement('div');
2970              recoveryNote.className = 'stopped-indicator';
2971              recoveryNote.innerHTML =
2972                `<span style="color: var(--color-error);">[${recoveryMsg}]</span>`;
2973              holder.querySelector('.body').appendChild(recoveryNote);
2974            }
2975            currentAbort = null;
2976            return;
2977          }
2978
2979          // User-initiated stop (or browser navigation abort).
2980          // Stopped before any text arrived — keep the bubble as a
2981          // "Cancelled by user" record (so it survives a refresh).
2982          if (holder && !accumulated) {
2983            _renderCancelledBubble(holder);
2984          }
2985
2986          // But just in case the stop button didn't render it, render it here
2987          if (holder && accumulated && !currentHolder) {
2988            holder.dataset.raw = accumulated;
2989            holder.querySelector('.body').innerHTML = markdownModule.processWithThinking(
2990              markdownModule.squashOutsideCode(accumulated)
2991            );
2992
2993            if (window.hljs) {
2994              holder.querySelectorAll('pre code').forEach((block) => {
2995                window.hljs.highlightElement(block);
2996              });
2997            }
2998
2999            const stoppedIndicator = document.createElement('div');
3000            stoppedIndicator.className = 'stopped-indicator';
3001            const stoppedLabel = document.createElement('span');
3002            stoppedLabel.textContent = '[Message interrupted]';
3003            stoppedIndicator.appendChild(stoppedLabel);
3004            const continueBtn = document.createElement('button');
3005            continueBtn.className = 'continue-btn';
3006            continueBtn.title = 'Continue';
3007            continueBtn.textContent = '\u25B8';
3008            continueBtn.addEventListener('click', () => {
3009              stoppedIndicator.remove();
3010              _hideUserBubble = true;
3011              _pendingContinue = holder;
3012              const cutoff = accumulated;
3013              const msgInput = uiModule.el('message');
3014              if (msgInput) {
3015                msgInput.value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff.slice(-500) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.';
3016                const sb = document.querySelector('.send-btn');
3017                if (sb) sb.click();
3018              }
3019            });
3020            stoppedIndicator.appendChild(continueBtn);
3021            holder.querySelector('.body').appendChild(stoppedIndicator);
3022
3023            // Tell server to mark this message as stopped
3024            const _sid2 = sessionModule.getCurrentSessionId();
3025            if (_sid2) fetch(`${API_BASE}/api/session/${_sid2}/mark-stopped`, { method: 'POST' }).catch(e => console.warn('mark-stopped failed:', e));
3026
3027            if (!holder.querySelector('.msg-footer')) {
3028              holder.appendChild(createMsgFooter(holder));
3029            }
3030
3031            uiModule.scrollHistory();
3032          }
3033
3034          // Now clear the abort controller
3035          currentAbort = null;
3036        } else {
3037          console.error(err);
3038          // Stream died with a tool node still spinning. Its per-node tickers
3039          // (_elapsedTicker 50ms / _waveInterval 100ms) are normally cleared in
3040          // `tool_output`, which will never arrive now — without this sweep they
3041          // fire forever on the orphaned node (and auto-recover compounds it per
3042          // nudge). Safe here: auto-recover's new send is deferred 200ms, so no
3043          // fresh running nodes exist yet.
3044          document.querySelectorAll('.agent-thread-node.running').forEach(node => {
3045            if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; }
3046            if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; }
3047            node.classList.remove('running');
3048          });
3049          // Stream died unexpectedly — the "silently died" case. Re-engage the
3050          // model immediately (no wait) with a completion handshake, up to the
3051          // cap. Only auto-recover from connection-class failures; deterministic
3052          // errors (unsupported tools, 4xx/5xx, parse failures) surface right away
3053          // instead of burning the nudge budget on a guaranteed-to-fail retry.
3054          if (!(_isRecoverableStreamErr(err) && _tryAutoRecover(holder, accumulated, streamSessionId))) {
3055            const errorHolder = document.querySelector('.msg-ai:last-of-type .body');
3056            if (errorHolder) {
3057              let errMsg = `Error: ${err.message}`;
3058              // Add hint for tool-call errors
3059              if (err.message && (err.message.includes('tool') || err.message.includes('auto'))) {
3060                errMsg += '\n\nThis model may not support tools — try switching to Chat mode.';
3061              }
3062              typewriterInto(errorHolder, errMsg);
3063            }
3064          }
3065        }
3066      }
3067    } finally {
3068      clearResponseTimeout();
3069      clearProcessingProbe();
3070      // Streaming done — let screen readers announce the settled response.
3071      const _chatLogDone = document.getElementById('chat-history');
3072      if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
3073      // Always clean up research tracking regardless of background state
3074      _researchingStreamIds.delete(streamSessionId);
3075      if (_researchingStreamIds.size === 0) {
3076        var _rToggleCleanup = document.getElementById('research-toggle-btn');
3077        if (_rToggleCleanup) _rToggleCleanup.classList.remove('research-running');
3078      }
3079
3080      // Only reset UI state if still on the stream's session and was never backgrounded
3081      const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
3082
3083      if (!_isBgFinally) {
3084        // Reset button to idle state
3085        updateSubmitButton('idle', submitBtn);
3086
3087        // Re-enable message input; on mobile blur to dismiss keyboard
3088        if (messageInput) {
3089          messageInput.disabled = false;
3090          if (window.innerWidth <= 768) {
3091            messageInput.blur();
3092          } else {
3093            messageInput.focus();
3094          }
3095        }
3096
3097        // Clear tracking variables
3098        currentAccumulated = '';
3099        currentHolder = null;
3100        currentSpinner = null;
3101        _researchingStreamIds.delete(streamSessionId);
3102        // Clear research-running highlight if no more active research
3103        if (_researchingStreamIds.size === 0) {
3104          var _rToggle2 = document.getElementById('research-toggle-btn');
3105          if (_rToggle2) _rToggle2.classList.remove('research-running');
3106        }
3107        _clearResearchTimer();
3108
3109        // Re-enable research button and auto-untoggle after use
3110        // (skip if clarification round — keep toggle on for follow-up)
3111        const _el = uiModule.el;
3112        const _researchBtn = _el('research-toggle-btn');
3113        const _researchToggle = _el('research-toggle');
3114        if (_researchToggle && _researchToggle.checked) {
3115          _researchToggle.checked = false;
3116          Storage.setToggle('research', false);
3117        }
3118        if (_researchBtn) {
3119          _researchBtn.disabled = false;
3120          _researchBtn.classList.remove('active');
3121          _researchBtn.style.display = 'none';
3122        }
3123        // Also sync overflow and tool sidebar buttons
3124        const _overflowRes = _el('overflow-research-btn');
3125        if (_overflowRes) _overflowRes.classList.remove('active');
3126        const _toolRes = _el('tool-research-btn');
3127        if (_toolRes) _toolRes.classList.remove('active');
3128
3129      }
3130
3131      // Research clarification timeout — if user doesn't reply within 5 min, show timeout
3132      if (holder && holder._roleSuffix === 'Research' && !_researchingStreamIds.has(streamSessionId)) {
3133        var _timeoutSessionId = streamSessionId;
3134        var _timeoutTimer = setTimeout(async function() {
3135          // Check if research_pending is still active (user hasn't replied)
3136          try {
3137            var _box = document.getElementById('chat-history');
3138            if (_box && sessionModule.getCurrentSessionId() === _timeoutSessionId) {
3139              var _timeoutMsg = document.createElement('div');
3140              _timeoutMsg.className = 'msg msg-ai';
3141              _timeoutMsg.innerHTML = '<div class="role">Odysseus</div><div class="body" style="opacity:0.6;font-style:italic;">Research clarification timed out. Toggle research again to start over.</div>';
3142              _box.appendChild(_timeoutMsg);
3143              uiModule.scrollHistory();
3144            }
3145          } catch(_te) {}
3146        }, 5 * 60 * 1000);
3147        // Cancel timeout if user sends a message
3148        var _origSubmit = window._researchTimeoutTimer;
3149        if (_origSubmit) clearTimeout(_origSubmit);
3150        window._researchTimeoutTimer = _timeoutTimer;
3151      }
3152
3153      // Release Web Lock
3154      if (_webLockRelease) {
3155        _webLockRelease();
3156        _webLockRelease = null;
3157      }
3158
3159      // Refresh session list after a delay (picks up auto-generated names)
3160      setTimeout(() => {
3161        if (sessionModule && sessionModule.loadSessions) {
3162          sessionModule.loadSessions();
3163        }
3164      }, 3000);
3165    }
3166  }
3167
3168  /**
3169   * Abort current chat request
3170   */
3171  // stopServer=true ONLY for an explicit user Stop. The run is now DETACHED
3172  // (survives tab close / navigation), so the generic abort used by cleanup
3173  // paths (session switch, delete, reader teardown on tab close) must NOT stop
3174  // the server run — otherwise closing the tab would kill the background task,
3175  // defeating the whole point. Only the Stop button cancels the server run.
3176  export function abortCurrentRequest(stopServer = false) {
3177    if (currentAbort) {
3178      currentAbort.abort();
3179      // Don't set to null here - let catch block handle it
3180    }
3181    if (stopServer) {
3182      try {
3183        const _sid = _streamSessionId
3184          || (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId());
3185        if (_sid) {
3186          fetch(`/api/chat/stop/${encodeURIComponent(_sid)}`, { method: 'POST', credentials: 'same-origin' }).catch(() => {});
3187        }
3188      } catch (_) {}
3189    }
3190  }
3191
3192  // ── Stall watchdog ──────────────────────────────────────────────
3193  // Auto-recover a turn whose stream died (connection drop) or went silent:
3194  // preserve the partial, then re-submit a completion handshake by reusing the
3195  // existing continue/resume path. Returns false at the cap so the caller can
3196  // surface the failure instead of nudging forever.
3197  // Only auto-recover from connection-class failures (the genuine "silently
3198  // died" case). Deterministic errors — unsupported tools, HTTP 4xx/5xx, JSON
3199  // parse failures — will fail identically on retry, so surfacing them
3200  // immediately is both more honest and avoids wasting the nudge budget.
3201  function _isRecoverableStreamErr(err) {
3202    if (!err) return false;
3203    if (err.name === 'TypeError') return true;   // fetch/reader network failure
3204    const m = (err.message || '').toLowerCase();
3205    if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(m)) return false;
3206    return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(m);
3207  }
3208
3209  function _tryAutoRecover(holder, accumulated, sessionId) {
3210    if (_autoNudges >= _AUTO_NUDGE_CAP) return false;
3211    _autoNudges++;
3212    if (holder && accumulated) {
3213      holder.dataset.raw = accumulated;
3214      try {
3215        holder.querySelector('.body').innerHTML =
3216          markdownModule.processWithThinking(markdownModule.squashOutsideCode(accumulated));
3217      } catch (_) {}
3218    }
3219    _pendingContinue = holder || null;   // merge the continuation into the same bubble
3220    _hideUserBubble = true;              // no user bubble for the handshake
3221    _autoContinuePending = true;         // don't reset the counter on this submit
3222    const _abandon = () => {             // clear the pending flags so they can't
3223      _pendingContinue = null;           // leak into whatever chat is now open
3224      _hideUserBubble = false;
3225      _autoContinuePending = false;
3226    };
3227    // Defer so the stream's finally resets state first — otherwise the send
3228    // button is still in "stop" mode and clicking it would toggle, not send.
3229    setTimeout(() => {
3230      // The stream that died may not be the chat the user is now looking at —
3231      // never inject the recovery handshake into the wrong conversation.
3232      if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) { _abandon(); return; }
3233      const msgInput = uiModule.el('message');
3234      const sb = document.querySelector('.send-btn');
3235      if (!msgInput || !sb) { _abandon(); return; }
3236      const tail = (accumulated || '').slice(-400);
3237      msgInput.value = tail
3238        ? `The stream dropped before you finished. It ended with:\n\n${tail}\n\nIf the task is fully complete, reply with just: DONE. Otherwise continue exactly where you left off and finish it — do not repeat what you already wrote.`
3239        : `The stream dropped before you produced anything. If the task is already done, reply with just: DONE. Otherwise complete it now.`;
3240      sb.click();
3241    }, 200);
3242    return true;
3243  }
3244
3245  function _removeStallBanner() {
3246    const b = document.getElementById('stall-banner');
3247    if (b) b.remove();
3248    _stallBannerShown = false;
3249  }
3250  function _showStallBanner(secs) {
3251    if (document.getElementById('stall-banner')) return;
3252    _stallBannerShown = true;
3253    const box = document.getElementById('chat-history');
3254    if (!box) return;
3255    const bar = document.createElement('div');
3256    bar.id = 'stall-banner';
3257    bar.className = 'stall-banner';
3258    const mins = Math.floor(secs / 60);
3259    const label = mins >= 1 ? `${mins}m` : `${secs}s`;
3260    bar.innerHTML = `<span class="stall-banner-txt">Quiet for ${label} — still working?</span>`;
3261    const cont = document.createElement('button');
3262    cont.className = 'stall-banner-btn';
3263    cont.textContent = 'Nudge it';
3264    cont.title = 'Stop the stalled stream and ask it to continue';
3265    cont.addEventListener('click', () => {
3266      _removeStallBanner();
3267      const mi = uiModule.el('message');
3268      if (mi) {
3269        mi.value = 'Are you still working? If you stopped, continue exactly where you left off and finish the task.';
3270        const sb = document.querySelector('.send-btn');
3271        if (sb) sb.click();
3272      }
3273    });
3274    const stop = document.createElement('button');
3275    stop.className = 'stall-banner-btn stall-banner-stop';
3276    stop.textContent = 'Stop';
3277    stop.addEventListener('click', () => { _removeStallBanner(); abortCurrentRequest(true); });
3278    bar.appendChild(cont);
3279    bar.appendChild(stop);
3280    box.appendChild(bar);
3281    if (uiModule.scrollHistory) uiModule.scrollHistory();
3282  }
3283  function _startStallWatchdog() {
3284    // Disabled: the server-side stall detector / auto-continue (agent
3285    // loop-breaker) handles quiet/stalled streams now, so the manual
3286    // "Quiet for Nm — still working?" banner is redundant (and annoying).
3287    if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; }
3288    _removeStallBanner();
3289  }
3290  function _stopStallWatchdog() {
3291    if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; }
3292    _removeStallBanner();
3293  }
3294
3295  /** Show a "Cancelled by user" record in `holder` and persist an empty
3296   *  assistant placeholder server-side so the turn survives a refresh.
3297   *  Called from both abort paths when no tokens had streamed yet. */
3298  function _renderCancelledBubble(holder) {
3299    if (!holder) return;
3300    holder.dataset.raw = '';
3301    const body = holder.querySelector('.body');
3302    if (body) {
3303      body.innerHTML = '';
3304      const indicator = document.createElement('div');
3305      indicator.className = 'stopped-indicator';
3306      const label = document.createElement('span');
3307      label.style.fontStyle = 'italic';
3308      label.style.opacity = '0.7';
3309      label.textContent = '[Cancelled by user]';
3310      indicator.appendChild(label);
3311      body.appendChild(indicator);
3312    }
3313    if (typeof createMsgFooter === 'function' && !holder.querySelector('.msg-footer')) {
3314      holder.appendChild(createMsgFooter(holder));
3315    }
3316    // Persist as an assistant message with stopped+cancelled metadata so the
3317    // chat-history loader renders the same indicator after a refresh.
3318    // Include the model name so the bubble header still shows which model
3319    // was running when the user hit Stop.
3320    const sid = sessionModule.getCurrentSessionId();
3321    if (sid) {
3322      let modelName = '';
3323      try { modelName = sessionModule.getCurrentModel?.() || ''; } catch {}
3324      // Fallback: pull from the holder's existing meta (the streaming
3325      // placeholder usually has the model set in the header already).
3326      if (!modelName) {
3327        modelName = holder.dataset.model
3328          || holder.querySelector('.msg-header .msg-model')?.textContent
3329          || '';
3330      }
3331      fetch(`${API_BASE}/api/session/${sid}/inject_messages`, {
3332        method: 'POST', credentials: 'same-origin',
3333        headers: { 'Content-Type': 'application/json' },
3334        body: JSON.stringify({
3335          messages: [{
3336            role: 'assistant',
3337            content: '',
3338            metadata: { stopped: true, cancelled: true, model: modelName },
3339          }],
3340        }),
3341      }).catch(() => {});
3342    }
3343  }
3344
3345  /**
3346   * Detach current stream to run in background instead of aborting.
3347   * Called when user switches sessions mid-stream.
3348   */
3349  export function detachCurrentStream(sessionId) {
3350    if (!isStreaming || !currentAbort) {
3351      // Not streaming — fall through to abort
3352      abortCurrentRequest();
3353      return;
3354    }
3355    // Store background stream state
3356    _backgroundStreams.set(sessionId, {
3357      status: 'running',
3358      accumulated: currentAccumulated,
3359      sourcesHtml: '',
3360      findingsData: null,
3361      abortCtrl: currentAbort,
3362      query: currentHolder ? (currentHolder._researchQuery || '') : '',
3363      metrics: null,
3364    });
3365    // Mark session with pulsing dot in sidebar
3366    if (sessionModule && sessionModule.markStreaming) {
3367      sessionModule.markStreaming(sessionId);
3368    }
3369    // Clear local state WITHOUT aborting the fetch
3370    currentAbort = null;
3371    isStreaming = false;
3372    currentHolder = null;
3373    currentAccumulated = '';
3374    // Reset submit button so the new chat is ready to send
3375    const submitBtn = document.querySelector('.send-btn');
3376    if (submitBtn) updateSubmitButton('idle', submitBtn);
3377  }
3378
3379  // _notifyStreamComplete and _insertStreamDoneToast now in chatStream.js
3380  var _notifyStreamComplete = chatStream.notifyStreamComplete;
3381  var _insertStreamDoneToast = chatStream.insertStreamDoneToast;
3382
3383  /**
3384   * Live-resume a chat run still streaming detached on the server (#2539).
3385   *
3386   * On session re-entry, GET /api/chat/resume/{id} replays the run's buffer then
3387   * streams live; reply tokens render as they arrive. On completion a plain text
3388   * reply is finalized in place (canonical bubble via chatRenderer.addMessage, no
3389   * reload); a "rich" reply (tool calls, sources, doc streaming, multi-round) is
3390   * reloaded from the DB so its full render stays faithful. Returns true if it
3391   * attached, false to let the caller fall back to spinner+poll.
3392   */
3393  export async function resumeStream(sessionId) {
3394    if (!sessionId) return false;
3395    if (hasActiveStream(sessionId)) return false;
3396
3397    let res;
3398    try {
3399      res = await fetch(`${API_BASE}/api/chat/resume/${sessionId}`);
3400    } catch (e) {
3401      return false;
3402    }
3403    if (!res.ok || !res.body) return false;
3404
3405    const box = document.getElementById('chat-history');
3406    if (!box) return false;
3407
3408    // Block duplicate re-attach attempts while this reader is live. A dedicated
3409    // set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this
3410    // for a same-tab POST stream and spawn its own spinner+poll on re-entry.
3411    _resumingStreams.add(sessionId);
3412
3413    const holder = document.createElement('div');
3414    holder.className = 'msg msg-ai';
3415    const meta = sessionModule.getSessions().find(s => s.id === sessionId);
3416    const roleLabel = _shortModel(meta && meta.model);
3417    const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
3418    holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) +
3419      ' <span class="role-timestamp">' + roleTs + '</span></div>' +
3420      '<div class="body"><div class="stream-content"></div></div>';
3421    _applyModelColor(holder.querySelector('.role'), meta && meta.model);
3422    const contentDiv = holder.querySelector('.stream-content');
3423    box.appendChild(holder);
3424
3425    const spinner = spinnerModule.create('Generating response...', 'right');
3426    holder.querySelector('.body').appendChild(spinner.createElement());
3427    spinner.start();
3428    uiModule.scrollHistory();
3429
3430    const reader = res.body.getReader();
3431    const decoder = new TextDecoder();
3432    let buffer = '';
3433    let roundText = '';
3434    let gotDelta = false;
3435    let leftSession = false;
3436    let metricsData = null;
3437    // "Rich" responses (tool calls, sources, doc streaming, multi-round) need the
3438    // full canonical render, which is rebuilt from the saved DB record on reload.
3439    // Plain text replies can be finalized in place without a reload.
3440    let rich = false;
3441
3442    const cleanup = () => {
3443      try { spinner.destroy(); } catch (_) {}
3444      _resumingStreams.delete(sessionId);
3445    };
3446
3447    const renderDelta = () => {
3448      const dt = stripToolBlocks(roundText);
3449      contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt));
3450      uiModule.scrollHistory();
3451    };
3452
3453    try {
3454      readLoop:
3455      while (true) {
3456        // User left this session: stop rendering, the run continues server-side.
3457        if (sessionModule.getCurrentSessionId &&
3458            sessionModule.getCurrentSessionId() !== sessionId) {
3459          leftSession = true;
3460          try { await reader.cancel(); } catch (_) {}
3461          break;
3462        }
3463        const { done, value } = await reader.read();
3464        if (done) break;
3465        buffer += decoder.decode(value, { stream: true });
3466        const parts = buffer.split('\n\n');
3467        buffer = parts.pop();
3468        for (const part of parts) {
3469          const line = part.split('\n').find(l => l.startsWith('data: '));
3470          if (!line) continue;
3471          const payload = line.slice(6);
3472          if (payload === '[DONE]') {
3473            try { await reader.cancel(); } catch (_) {}
3474            break readLoop;
3475          }
3476          let json;
3477          try { json = JSON.parse(payload); } catch (_) { continue; }
3478          if (json.delta) {
3479            roundText += json.delta;
3480            if (!gotDelta) { gotDelta = true; try { spinner.destroy(); } catch (_) {} }
3481            renderDelta();
3482          } else if (json.type === 'doc_stream_open') {
3483            rich = true;
3484            if (documentModule) documentModule.streamDocOpen(json.title || '', json.lang || '');
3485          } else if (json.type === 'doc_stream_delta') {
3486            rich = true;
3487            if (documentModule && json.delta) documentModule.streamDocDelta(json.delta);
3488          } else if (json.type === 'metrics') {
3489            metricsData = json.data || metricsData;
3490          } else if (json.type === 'tool_start' || json.type === 'tool_output' ||
3491                     json.type === 'tool_progress' || json.type === 'agent_step' ||
3492                     json.type === 'web_sources' || json.type === 'rag_sources' ||
3493                     json.type === 'research_progress' || json.type === 'research_sources' ||
3494                     json.type === 'research_findings' || json.type === 'research_done') {
3495            rich = true;
3496          }
3497        }
3498      }
3499    } catch (e) {
3500      // Network drop or parse failure: fall through to the reload below.
3501    }
3502
3503    cleanup();
3504    if (leftSession) { if (holder.parentNode) holder.remove(); return true; }
3505
3506    const onThisSession = sessionModule.getCurrentSessionId &&
3507                          sessionModule.getCurrentSessionId() === sessionId;
3508
3509    // Plain text reply: finalize in place. Replace the live bubble with a
3510    // canonical single message (markdown + footer actions + metrics) using the
3511    // same renderer history does. No history refetch, no end-of-stream flicker.
3512    if (onThisSession && !rich && roundText.trim()) {
3513      if (holder.parentNode) holder.remove();
3514      const model = meta && meta.model;
3515      const meta_ = metricsData ? Object.assign({ model }, metricsData) : { model };
3516      chatRenderer.addMessage('assistant', roundText, model, meta_);
3517      uiModule.scrollHistory();
3518      return true;
3519    }
3520
3521    // Rich response (tools, sources, docs, multi-round) or user moved on:
3522    // reload from the DB for the full canonical render.
3523    if (holder.parentNode) holder.remove();
3524    if (onThisSession) sessionModule.selectSession(sessionId);
3525    else sessionModule.loadSessions();
3526    return true;
3527  }
3528
3529  /**
3530   * Check for background streams when switching to a session.
3531   * Called after history loads on session switch.
3532   */
3533  export function checkBackgroundStream(sessionId) {
3534    if (!sessionId || !_backgroundStreams.has(sessionId)) return;
3535    var entry = _backgroundStreams.get(sessionId);
3536
3537    if (entry.status === 'completed') {
3538      // Response is already saved to DB and will appear in history — just clean up
3539      _backgroundStreams.delete(sessionId);
3540      return;
3541    }
3542
3543    if (entry.status === 'error') {
3544      _backgroundStreams.delete(sessionId);
3545      var box = document.getElementById('chat-history');
3546      if (box) {
3547        var errHolder = document.createElement('div');
3548        errHolder.className = 'msg msg-ai';
3549        errHolder.innerHTML = '<div class="body"><i style="color: var(--color-error);">[Background stream encountered an error]</i></div>';
3550        box.appendChild(errHolder);
3551      }
3552      return;
3553    }
3554
3555    if (entry.status === 'running') {
3556      // Stream is still active — show a clean spinner, poll until done,
3557      // then reload history to show the final saved response.
3558      var box = document.getElementById('chat-history');
3559      if (!box) return;
3560
3561      // Replay any doc content that was streamed in the background
3562      if (entry._docTitle != null && documentModule) {
3563        documentModule.streamDocOpen(entry._docTitle, entry._docLang || '');
3564        if (entry._docContent) {
3565          documentModule.streamDocDelta(entry._docContent);
3566        }
3567      }
3568
3569      var holder = document.createElement('div');
3570      holder.className = 'msg msg-ai';
3571      var meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; });
3572      var roleLabel = _shortModel(meta && meta.model);
3573      var roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
3574      holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>';
3575      _applyModelColor(holder.querySelector('.role'), meta && meta.model);
3576
3577      var bodyDiv = holder.querySelector('.body');
3578      var spinner = spinnerModule.create('Response streaming in background', 'right');
3579      bodyDiv.appendChild(spinner.createElement());
3580      spinner.start();
3581
3582      box.appendChild(holder);
3583      uiModule.scrollHistory();
3584
3585      // Poll map until stream finishes, then reload history
3586      var pollId = setInterval(function() {
3587        if (sessionModule.getCurrentSessionId() !== sessionId) {
3588          clearInterval(pollId);
3589          spinner.destroy();
3590          if (holder.parentNode) holder.remove();
3591          return;
3592        }
3593        // Update doc content while polling
3594        var curPoll = _backgroundStreams.get(sessionId);
3595        if (curPoll && curPoll._docContent && documentModule) {
3596          documentModule.streamDocDelta(curPoll._docContent);
3597        }
3598        if (!curPoll || curPoll.status !== 'running') {
3599          clearInterval(pollId);
3600          spinner.destroy();
3601          if (holder.parentNode) holder.remove(); // Remove entire holder, not just spinner
3602          _backgroundStreams.delete(sessionId);
3603          // Reload session to show the completed response — but only if the user
3604          // is still on it; don't yank them back from a new chat they opened.
3605          if (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() === sessionId) {
3606            sessionModule.selectSession(sessionId);
3607          } else {
3608            sessionModule.loadSessions();
3609          }
3610        }
3611      }, 500);
3612    }
3613  }
3614
3615  // Tag short single-line code blocks with .pre-compact so the CSS can
3616  // render the Run/Edit/Copy buttons as a slim row that doesn't make a
3617  // 1-line bash block taller than its own contents.
3618  function _markCompactPre(pre) {
3619    const code = pre.querySelector('code');
3620    if (!code) return;
3621    const txt = code.textContent || '';
3622    // Count visible lines — ignore trailing newline (common with fenced
3623    // blocks) and treat any empty extra line as not a real second line.
3624    const lines = txt.replace(/\n+$/, '').split('\n');
3625    const compact = lines.length <= 1 && txt.length < 200;
3626    pre.classList.toggle('pre-compact', compact);
3627  }
3628  function _scanCompactPres(root) {
3629    if (!root || !root.querySelectorAll) return;
3630    root.querySelectorAll('pre').forEach(_markCompactPre);
3631  }
3632  // Global observer so any <pre> added anywhere in the app (chat stream,
3633  // chat re-renders, document library chat previews, slash commands,
3634  // research previews, etc.) gets tagged without each call site needing
3635  // to remember.
3636  (function _initCompactPreObserver() {
3637    if (window._cmpPreObserverWired) return;
3638    window._cmpPreObserverWired = true;
3639    _scanCompactPres(document.body);
3640    const obs = new MutationObserver((muts) => {
3641      for (const m of muts) {
3642        for (const n of m.addedNodes) {
3643          if (n.nodeType !== 1) continue;
3644          if (n.tagName === 'PRE') _markCompactPre(n);
3645          if (n.querySelectorAll) _scanCompactPres(n);
3646        }
3647      }
3648    });
3649    obs.observe(document.body, { childList: true, subtree: true });
3650  })();
3651
3652  /**
3653   * Initialize event listeners
3654   */
3655  export function initListeners() {
3656    // Global event delegation for copy-code buttons
3657    document.addEventListener('click', (e) => {
3658      const btn = e.target.closest('.copy-code');
3659      if (!btn) return;
3660      e.stopPropagation();
3661      const code = btn.getAttribute('data-code');
3662      if (code && uiModule) {
3663        uiModule.copyToClipboard(code);
3664        // Visual feedback: swap the icon to a checkmark (regular size)
3665        // and add .copied which the CSS uses to flash green + pulse.
3666        // For slim/.pre-compact buttons the label text comes from a
3667        // CSS ::before — swap it via data-state so we don't break the
3668        // text-button layout.
3669        const origHTML = btn.innerHTML;
3670        const isCompact = !!btn.closest('pre.pre-compact');
3671        if (!isCompact) {
3672          btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
3673        }
3674        btn.classList.add('copied');
3675        btn.dataset.state = 'copied';
3676        setTimeout(() => {
3677          if (!isCompact) btn.innerHTML = origHTML;
3678          btn.classList.remove('copied');
3679          delete btn.dataset.state;
3680        }, 1500);
3681      }
3682    });
3683
3684    // Run code button delegation
3685    document.addEventListener('click', (e) => {
3686      const btn = e.target.closest('.run-code');
3687      if (!btn) return;
3688      e.stopPropagation();
3689      if (codeRunnerModule) codeRunnerModule.run(btn);
3690    });
3691
3692    // Edit code button delegation — toggle contentEditable on the code element
3693    document.addEventListener('click', (e) => {
3694      const btn = e.target.closest('.edit-code');
3695      if (!btn) return;
3696      e.stopPropagation();
3697      const pre = btn.closest('pre');
3698      if (!pre) return;
3699      const codeEl = pre.querySelector('code');
3700      if (!codeEl) return;
3701      const isEditing = codeEl.contentEditable !== 'false' && codeEl.contentEditable !== 'inherit';
3702      if (isEditing) {
3703        // Save: exit edit mode, update data-code on copy/run buttons
3704        codeEl.contentEditable = 'false';
3705        codeEl.classList.remove('editing');
3706        pre.classList.remove('editing');
3707        const newCode = codeEl.textContent;
3708        const copyBtn = pre.querySelector('.copy-code');
3709        if (copyBtn) copyBtn.setAttribute('data-code', newCode);
3710        const runBtn = pre.querySelector('.run-code');
3711        if (runBtn) runBtn.setAttribute('data-code', newCode);
3712        // Swap icon back to pencil
3713        btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
3714        btn.title = 'Edit';
3715        btn.classList.remove('active');
3716      } else {
3717        // Enter edit mode. Firefox (especially on mobile) historically lacks
3718        // contentEditable="plaintext-only" — setting it there leaves the block
3719        // non-editable, so the tap "just gets a checkmark" with no way to type.
3720        // Fall back to "true" when plaintext-only didn't take.
3721        try { codeEl.contentEditable = 'plaintext-only'; } catch (_) { /* unsupported value */ }
3722        if (codeEl.contentEditable !== 'plaintext-only') codeEl.contentEditable = 'true';
3723        codeEl.classList.add('editing');
3724        pre.classList.add('editing');
3725        // preventScroll keeps the page from jumping to the codeblock when
3726        // focusing the editable on mobile — the browser would otherwise
3727        // scroll it into view above the keyboard, which reads as "auto-
3728        // scroll triggered by clicking Edit".
3729        try { codeEl.focus({ preventScroll: true }); } catch (_) { codeEl.focus(); }
3730        // Swap icon to checkmark
3731        btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
3732        btn.title = 'Done editing';
3733        btn.classList.add('active');
3734      }
3735    });
3736
3737    // Tapping a code block body (not its buttons) toggles the overlay
3738    // copy/edit/run buttons, which otherwise cover the text on mobile.
3739    document.addEventListener('click', (e) => {
3740      if (e.target.closest('.copy-code, .edit-code, .run-code')) return;
3741      const pre = e.target.closest('pre');
3742      if (!pre || !pre.querySelector('.copy-code')) return;
3743      // Don't hide while editing — the buttons (incl. the Done checkmark) matter.
3744      if (pre.classList.contains('editing')) return;
3745      pre.classList.toggle('buttons-hidden');
3746    });
3747
3748    // Position copy/run buttons top or bottom based on viewport position
3749    // — DESKTOP ONLY. On mobile this was constantly retriggering on tap
3750    // (synthetic mouseenter) and made the buttons jump, so the user's
3751    // finger landed on the moved target. Keep them pinned at the top on
3752    // touch — no auto-repositioning.
3753    document.addEventListener('mouseenter', (e) => {
3754      if (window.matchMedia('(max-width: 768px)').matches) return;
3755      const pre = e.target.closest ? e.target.closest('pre') : null;
3756      if (!pre || pre.dataset.btnPosComputed) return;
3757      const rect = pre.getBoundingClientRect();
3758      const threshold = window.innerHeight * 0.35;
3759      const isBottom = rect.top < threshold;
3760      const copyBtn = pre.querySelector('.copy-code');
3761      if (copyBtn) copyBtn.classList.toggle('bottom', isBottom);
3762      const editBtn = pre.querySelector('.edit-code');
3763      if (editBtn) editBtn.classList.toggle('bottom', isBottom);
3764      const runBtn = pre.querySelector('.run-code');
3765      if (runBtn) runBtn.classList.toggle('bottom', isBottom);
3766      pre.dataset.btnPosComputed = '1';
3767    }, true);
3768
3769    // Tab suspension recovery: when user tabs back in, check if stream froze
3770    document.addEventListener('visibilitychange', () => {
3771      if (document.visibilityState !== 'visible') return;
3772      if (!isStreaming) return;
3773
3774      // Stream claims to be running — check if reader is actually alive
3775      const staleSince = Date.now() - _lastReaderActivity;
3776      if (staleSince < 20000) return;
3776 // Active recently, probably fine
3777
3778      // Reader hasn't produced data in 5+ seconds after tab resume.
3779      // Give it a short grace period then recover.
3780      console.warn('[tab-recovery] Stream appears frozen (no activity for ' + Math.round(staleSince/1000) + 's). Recovering...');
3781
3782      setTimeout(() => {
3783        // Re-check — maybe the reader woke up during the grace period
3784        if (!isStreaming) return;
3785        const stillStale = Date.now() - _lastReaderActivity;
3786        if (stillStale < 5000) return; // Came back to life
3787
3788        console.warn('[tab-recovery] Stream confirmed dead. Aborting and reloading session.');
3789
3790        // Abort the frozen stream, but preserve the visible bubble.
3791        if (currentAbort) {
3792          currentAbort._reason = 'recovery';
3793          currentAbort.abort();
3794        }
3795        isStreaming = false;
3796
3797        // Release Web Lock
3798        if (_webLockRelease) {
3799          _webLockRelease();
3800          _webLockRelease = null;
3801        }
3802
3803        // Reset UI state
3804        var _submitBtn = document.getElementById('submit');
3805        updateSubmitButton('idle', _submitBtn);
3806        var _msgInput = document.getElementById('message');
3807        if (_msgInput) _msgInput.disabled = false;
3808      }, 2000); // 2 second grace period
3809    });
3810
3811    // On mobile, fade out welcome text when keyboard opens to prevent overlap
3812    if (window.innerWidth <= 768) {
3813      const msgInput = document.getElementById('message');
3814      if (msgInput) {
3815        msgInput.addEventListener('focus', () => {
3816          const ws = document.getElementById('welcome-screen');
3817          if (ws && !ws.classList.contains('hidden')) {
3818            ws.classList.add('kb-hidden');
3819          }
3820        });
3821        msgInput.addEventListener('blur', () => {
3822          const ws = document.getElementById('welcome-screen');
3823          if (ws && !ws.classList.contains('hidden')) {
3824            // Delay re-show so tapping within chatbox doesn't flash
3825            setTimeout(() => {
3826              if (document.activeElement !== msgInput) {
3827                ws.classList.remove('kb-hidden');
3828              }
3829            }, 200);
3830          }
3831        });
3832      }
3833      // Smooth viewport resize when keyboard opens/closes
3834      if (window.visualViewport) {
3835        window.visualViewport.addEventListener('resize', () => {
3836          document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
3837        });
3838        document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
3839      }
3840    }
3841
3842    // If the browser discarded and restored this tab, reload the current session
3843    // so the user sees the server-saved partial response instead of a blank page
3844    if (document.wasDiscarded) {
3845      console.warn('[tab-recovery] Tab was discarded by browser — reloading session');
3846      setTimeout(() => {
3847        var _sid = sessionModule && sessionModule.getCurrentSessionId();
3848        if (_sid) sessionModule.selectSession(_sid);
3849      }, 500);
3850    }
3851  }
3852
3853  /**
3854   * Regenerate response: truncate history to the user message before this AI message,
3855   * then re-submit that user message.
3856   */
3857  /**
3858   * Edit a user message: show an input, truncate to before it, resubmit the edited text.
3859   */
3860  export async function editUserMessage(userMsgElement) {
3861    const box = document.getElementById('chat-history');
3862    const allMsgs = Array.from(box.querySelectorAll('.msg'));
3863    const msgIndex = allMsgs.indexOf(userMsgElement);
3864    if (msgIndex < 0) return;
3865
3866    const bodyEl = userMsgElement.querySelector('.body');
3867    const currentText = bodyEl ? bodyEl.textContent.trim().replace(/\s*\[\d+ attachment\(s\)\]$/, '') : '';
3868
3869    // Replace body with an editable textarea
3870    const editor = document.createElement('textarea');
3871    editor.className = 'edit-textarea';
3872    editor.value = currentText;
3873    editor.rows = Math.max(2, currentText.split('\n').length);
3874
3875    const btnRow = document.createElement('div');
3876    btnRow.style.cssText = 'display:flex; gap:6px; margin-top:4px;';
3877
3878    const saveBtn = document.createElement('button');
3879    saveBtn.className = 'edit-save-btn';
3880    saveBtn.textContent = 'Send';
3881    const cancelBtn = document.createElement('button');
3882    cancelBtn.className = 'edit-cancel-btn';
3883    cancelBtn.textContent = 'Cancel';
3884    btnRow.appendChild(saveBtn);
3885    btnRow.appendChild(cancelBtn);
3886
3887    const originalHTML = bodyEl.innerHTML;
3888    bodyEl.innerHTML = '';
3889    bodyEl.appendChild(editor);
3890    bodyEl.appendChild(btnRow);
3891    editor.focus();
3892
3893    cancelBtn.addEventListener('click', (e) => {
3894      e.stopPropagation();
3895      bodyEl.innerHTML = originalHTML;
3896    });
3897
3898    saveBtn.addEventListener('click', async (e) => {
3899      e.stopPropagation();
3900      const newText = editor.value.trim();
3901      if (!newText) return;
3902
3903      const sessionId = sessionModule.getCurrentSessionId();
3904      if (!sessionId) return;
3905
3906      const keepCount = msgIndex;
3907      try {
3908        await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
3909          method: 'POST',
3910          headers: { 'Content-Type': 'application/json' },
3911          body: JSON.stringify({ keep_count: keepCount })
3912        });
3913
3914        // Remove DOM elements from msgIndex onward
3915        for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
3916          allMsgs[i].remove();
3917        }
3918
3919        // Submit the edited text
3920        const messageInput = uiModule.el('message');
3921        messageInput.value = newText;
3922        const submitBtn = document.querySelector('.send-btn');
3923        if (submitBtn) submitBtn.click();
3924      } catch (err) {
3925        console.error('Edit failed:', err);
3926        if (uiModule) uiModule.showError('Edit failed: ' + err.message);
3927        bodyEl.innerHTML = originalHTML;
3928      }
3929    });
3930
3931    // Also submit on Enter (without shift)
3932    editor.addEventListener('keydown', (e) => {
3933      if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
3934        e.preventDefault();
3935        saveBtn.click();
3936      }
3937    });
3938  }
3939
3940  /**
3941   * Resend a user message — truncates history to that point and resubmits.
3942   */
3943  export async function resendUserMessage(userMsgElement) {
3944    const box = document.getElementById('chat-history');
3945    const allMsgs = Array.from(box.querySelectorAll('.msg'));
3946    const msgIndex = allMsgs.indexOf(userMsgElement);
3947    if (msgIndex < 0) return;
3948
3949    // Prefer dataset.raw (stripped original user text) over .body.textContent
3950    // — the latter slurps the rendered "View image description" collapsible
3951    // content too, which would then be sent back as the user's question and
3952    // the AI would reply to that gibberish instead of the actual prompt.
3953    const bodyEl = userMsgElement.querySelector('.body');
3954    let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
3955    text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
3956
3957    // Collect file_ids attached to this user message so the resend re-carries
3958    // the photos / docs (and the chat handler picks up the user-edited OCR
3959    // text cached server-side under those file ids).
3960    const _attachEls = userMsgElement.querySelectorAll('[data-file-id]');
3961    let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
3962    if (!_ids.length) {
3963      const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
3964      for (const _im of _imgs) {
3965        const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
3966        if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]);
3967      }
3968    }
3969
3970    // Rescue: legacy bubbles may have stored the filename as the message
3971    // content (artifact of earlier broken resends). Don't re-send that as
3972    // the user prompt if we still have the file attached. Loosen the regex
3973    // to cover real-world camera/screenshot names with spaces, parens,
3974    // multi-dots: "Screen Shot 2026-05-28 at 4.05.32 PM.png", "IMG (1).JPG".
3975    if (text && _ids.length && /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(text)) {
3976      text = '';
3977    }
3978    // Empty text + no attachments → tell the user instead of silently bailing.
3979    // The common case is a regen during a pre-upload race where the bubble
3980    // never had an `[data-file-id]` to scrape.
3981    if (!text && !_ids.length) {
3982      if (uiModule?.showError) uiModule.showError('Nothing to resend — message has no text and no attachments yet (try again after the upload finishes).');
3983      return;
3984    }
3985
3986    const sessionId = sessionModule.getCurrentSessionId();
3987    if (!sessionId) return;
3988
3989    // Truncate backend to keep everything before this user message
3990    const keepCount = msgIndex;
3991    try {
3992      await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
3993        method: 'POST',
3994        headers: { 'Content-Type': 'application/json' },
3995        body: JSON.stringify({ keep_count: keepCount })
3996      });
3997
3998      // Drop the AI replies after the user message but KEEP the user bubble
3999      // itself (so its photo stays visible). Then suppress the new user
4000      // bubble that send would otherwise add — same pattern as regenerate.
4001      let sibling = userMsgElement.nextSibling;
4002      while (sibling) {
4003        const next = sibling.nextSibling;
4004        sibling.remove();
4005        sibling = next;
4006      }
4007      _hideUserBubble = true;
4008      _pendingRegenAttachments = _ids;
4009
4010      // Resubmit
4011      const messageInput = uiModule.el('message');
4012      messageInput.value = text;
4013      const submitBtn = document.querySelector('.send-btn');
4014      if (submitBtn) submitBtn.click();
4015    } catch (err) {
4016      console.error('Resend failed:', err);
4017      if (uiModule) uiModule.showError('Resend failed: ' + err.message);
4018    }
4019  }
4020
4021  export async function regenerateFrom(aiMsgElement) {
4022    const box = document.getElementById('chat-history');
4023    const allMsgs = Array.from(box.querySelectorAll('.msg'));
4024    const aiIndex = allMsgs.indexOf(aiMsgElement);
4025    if (aiIndex < 0) return;
4026
4027    // Find the preceding user message
4028    let userIndex = -1;
4029    let userText = '';
4030    let userMsgEl = null;
4031    for (let i = aiIndex - 1; i >= 0; i--) {
4032      if (allMsgs[i].classList.contains('msg-user')) {
4033        userIndex = i;
4034        userMsgEl = allMsgs[i];
4035        // Prefer dataset.raw (set by addMessage with the stripped, original
4036        // user text) over the rendered body's textContent — the latter
4037        // pulls in the "View image description" collapsible content too,
4038        // duplicating the OCR text on regen.
4039        const bodyEl = userMsgEl.querySelector('.body');
4040        userText = (userMsgEl.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
4041        userText = userText.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
4042        break;
4043      }
4044    }
4045
4046    if (userIndex < 0) {
4047      if (uiModule) uiModule.showError('Could not find the user message to regenerate');
4048      return;
4049    }
4050
4051    // Collect any file_ids attached to the original user message so the
4052    // regenerated send re-uses them. Without this the AI is regenerated on
4053    // text alone — photos (and the user-edited OCR text cached server-side
4054    // under that file_id) would be silently dropped.
4055    const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
4056    let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
4057    // Fallback for bubbles rendered before the data-file-id stamp landed:
4058    // sniff the file id straight out of any `.attach-image-preview img`
4059    // src URLs (matches /api/upload/<id>). Otherwise an older bubble would
4060    // regen with zero attachments and the photo would be lost from the
4061    // resulting message even though the file still exists on disk.
4062    if (!_regenIds.length && userMsgEl) {
4063      const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img');
4064      for (const _im of _imgs) {
4065        const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
4066        if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]);
4067      }
4068    }
4069    _pendingRegenAttachments = _regenIds;
4070
4071    // Rescue: earlier-version regens (before the dataset.raw fix) stored the
4072    // photo's filename as the user-message content. On a follow-up regen,
4073    // that filename would be sent back as the literal user prompt, so the
4074    // AI thinks the question is "blue_night_preview.jpg" and replies "that's
4075    // an image file". If userText is just a bare image filename and we have
4076    // attachments, drop it so the OCR text (or the image bytes for vision
4077    // models) is what the model actually sees.
4078    if (userText && _pendingRegenAttachments.length &&
4079        /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
4080      userText = '';
4081    }
4082
4083    // A photo-only message has empty user text — regen must still proceed,
4084    // because the attachments themselves are the message. Bail only if there
4085    // is no text AND no attachments to send.
4086    if (!userText && !_pendingRegenAttachments.length) {
4087      if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
4088      return;
4089    }
4090
4091    const sessionId = sessionModule.getCurrentSessionId();
4092    if (!sessionId) return;
4093
4094    // Save current response as a variant
4095    const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
4096    const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
4097    let variants = [];
4098    try { variants = JSON.parse(aiMsgElement.dataset.variants || '[]'); } catch(_) {}
4099    if (variants.length === 0) {
4100      // First regen — save the original as variant 0
4101      variants.push({ raw: oldRaw, html: oldHtml, label: 'original' });
4102    }
4103
4104    const keepCount = userIndex;
4105
4106    try {
4107      await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
4108        method: 'POST',
4109        headers: { 'Content-Type': 'application/json' },
4110        body: JSON.stringify({ keep_count: keepCount })
4111      });
4112
4113      for (let i = allMsgs.length - 1; i > aiIndex; i--) {
4114        allMsgs[i].remove();
4115      }
4116
4117      // Remove the AI message from DOM — it will be replaced by the new streaming response
4118      // But first, stash the variants data so we can transfer it to the new element
4119      _pendingVariants = variants;
4120      _pendingVariantLabel = 'regen';
4121      aiMsgElement.remove();
4122
4123      _hideUserBubble = true;
4124      const messageInput = uiModule.el('message');
4125      messageInput.value = userText;
4126      const submitBtn = document.querySelector('.send-btn');
4127      if (submitBtn) submitBtn.click();
4128
4129    } catch (err) {
4130      console.error('Regenerate failed:', err);
4131      if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
4132    }
4133  }
4134
4135  // Pending variants from a regeneration — transferred to new streaming element
4136  let _pendingVariants = null;
4137  let _pendingVariantLabel = null;
4138  // File-ids carried over from the original user message during a regen, so
4139  // photos / OCR overrides survive into the new send. Consumed once.
4140  let _pendingRegenAttachments = null;
4141
4142  /**
4143   * Called after streaming completes to attach variant navigation if this was a regen.
4144   */
4145  function _attachVariantNav(msgElement) {
4146    if (!_pendingVariants) return;
4147    const variants = _pendingVariants;
4148    _pendingVariants = null;
4149
4150    // Add the new response as the latest variant
4151    const newRaw = msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || '';
4152    const newHtml = msgElement.querySelector('.body')?.innerHTML || '';
4153    const varLabel = _pendingVariantLabel || 'regen';
4154    _pendingVariantLabel = null;
4155    variants.push({ raw: newRaw, html: newHtml, label: varLabel });
4156
4157    msgElement.dataset.variants = JSON.stringify(variants);
4158    msgElement.dataset.variantIndex = String(variants.length - 1);
4159
4160    _renderVariantNav(msgElement, variants, variants.length - 1);
4161
4162    // Persist variants to server
4163    const sid = sessionModule.getCurrentSessionId();
4164    if (sid) {
4165      fetch(`${API_BASE}/api/session/${sid}/update-last-meta`, {
4166        method: 'POST',
4167        headers: { 'Content-Type': 'application/json' },
4168        body: JSON.stringify({ metadata: { variants: variants, variantIndex: variants.length - 1 } })
4169      }).catch(e => console.warn('update-last-meta (variants) failed:', e));
4170    }
4171  }
4172
4173  const _VARIANT_ICONS = { regen: '\u21BB', shorter: '\u2702', simpler: '?', original: '\u25CB' };
4174  function _variantTagText(label) {
4175    return _VARIANT_ICONS[label] || _VARIANT_ICONS['original'];
4176  }
4177
4178  function _renderVariantNav(msgElement, variants, currentIdx) {
4179    // Remove existing nav if any
4180    const old = msgElement.querySelector('.variant-nav');
4181    if (old) old.remove();
4182
4183    if (variants.length < 2) return;
4184
4185    const nav = document.createElement('span');
4186    nav.className = 'variant-nav';
4187    nav.addEventListener('click', (e) => e.stopPropagation());
4188
4189    // Label showing what this variant is
4190    // Divider
4191    const divider = document.createElement('span');
4192    divider.className = 'variant-divider';
4193    divider.textContent = '|';
4194    nav.appendChild(divider);
4195
4196    // Label
4197    const curVariant = variants[currentIdx];
4198    const tagLabel = document.createElement('span');
4199    tagLabel.className = 'variant-tag' + (curVariant?.label === 'shorter' ? ' variant-tag-scissors' : '');
4200    tagLabel.textContent = _variantTagText(curVariant?.label);
4201    nav.appendChild(tagLabel);
4202
4203    // < button
4204    const prevBtn = document.createElement('button');
4205    prevBtn.className = 'variant-btn';
4206    prevBtn.textContent = '<';
4207    prevBtn.disabled = currentIdx === 0;
4208    prevBtn.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx - 1); });
4209    nav.appendChild(prevBtn);
4210
4211    // Clickable number for current index (click left number = go left, right = go right)
4212    const numLeft = document.createElement('button');
4213    numLeft.className = 'variant-num';
4214    numLeft.textContent = String(currentIdx + 1);
4215    numLeft.disabled = currentIdx === 0;
4216    numLeft.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx - 1); });
4217    nav.appendChild(numLeft);
4218
4219    const slash = document.createElement('span');
4220    slash.className = 'variant-slash';
4221    slash.textContent = '/';
4222    nav.appendChild(slash);
4223
4224    const numRight = document.createElement('button');
4225    numRight.className = 'variant-num';
4226    numRight.textContent = String(variants.length);
4227    numRight.disabled = currentIdx === variants.length - 1;
4228    numRight.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx + 1); });
4229    nav.appendChild(numRight);
4230
4231    // > button
4232    const nextBtn = document.createElement('button');
4233    nextBtn.className = 'variant-btn';
4234    nextBtn.textContent = '>';
4235    nextBtn.disabled = currentIdx === variants.length - 1;
4236    nextBtn.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx + 1); });
4237    nav.appendChild(nextBtn);
4238
4239    // Insert into the .role header
4240    const roleEl = msgElement.querySelector('.role');
4241    if (roleEl) {
4242      roleEl.appendChild(nav);
4243    } else {
4244      msgElement.appendChild(nav);
4245    }
4246  }
4247
4248  function _switchVariant(msgElement, variants, newIdx) {
4249    if (newIdx < 0 || newIdx >= variants.length) return;
4250    const v = variants[newIdx];
4251    const body = msgElement.querySelector('.body');
4252    if (body) body.innerHTML = v.html;
4253    msgElement.dataset.raw = v.raw;
4254    msgElement.dataset.variantIndex = String(newIdx);
4255    if (window.hljs) {
4256      msgElement.querySelectorAll('pre code').forEach(block => window.hljs.highlightElement(block));
4257    }
4258    _renderVariantNav(msgElement, variants, newIdx);
4259
4260    // Persist selected variant to server
4261    const sid = sessionModule.getCurrentSessionId();
4262    if (sid) {
4263      fetch(`${API_BASE}/api/session/${sid}/update-last-meta`, {
4264        method: 'POST',
4265        headers: { 'Content-Type': 'application/json' },
4266        body: JSON.stringify({ metadata: { variantIndex: newIdx } })
4267      }).catch(e => console.warn('update-last-meta (variantIndex) failed:', e));
4268    }
4269  }
4270
4271  export async function forkFrom(aiMsgElement) {
4272    const box = document.getElementById('chat-history');
4273    const allMsgs = Array.from(box.querySelectorAll('.msg'));
4274    const aiIndex = allMsgs.indexOf(aiMsgElement);
4275    if (aiIndex < 0) return;
4276
4277    const sessionId = sessionModule.getCurrentSessionId();
4278    if (!sessionId) return;
4279
4280    const keepCount = aiIndex + 1;
4281
4282    try {
4283      const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
4284        method: 'POST',
4285        headers: { 'Content-Type': 'application/json' },
4286        body: JSON.stringify({ keep_count: keepCount }),
4287      });
4288      if (!res.ok) throw new Error(await res.text());
4289      const data = await res.json();
4290
4291      await sessionModule.loadSessions();
4292      await sessionModule.selectSession(data.id);
4293      if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
4294    } catch (err) {
4295      console.error('Fork failed:', err);
4296      if (uiModule) uiModule.showError('Fork failed: ' + err.message);
4297    }
4298  }
4299
4300  /**
4301   * Check for pending/completed research after page refresh or session switch.
4302   * If research is still running, show a spinner and poll until done.
4303   * If research is done, fetch result and render it.
4304   */
4305  export async function checkPendingResearch(sessionId) {
4306    if (!sessionId) return;
4307    try {
4308      const res = await fetch(`${API_BASE}/api/research/status/${sessionId}`);
4309      if (!res.ok) return; // 404 = no research for this session
4310      const data = await res.json();
4311
4312      if (data.status === 'done') {
4313        // Fetch and render the completed result
4314        _notifyResearchComplete(sessionId, data.query || '');
4315        if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId);
4316        const resultRes = await fetch(`${API_BASE}/api/research/result/${sessionId}`, { method: 'POST' });
4317        if (resultRes.ok) {
4318          const resultData = await resultRes.json();
4319          if (resultData.result) {
4320            // Skip if history already has a research message for this session
4321            if (document.querySelector(`#chat-history .msg-ai[data-research-session="${sessionId}"]`)) return;
4322
4323            var srcBox = '';
4324            if (resultData.sources && resultData.sources.length > 0) {
4325              srcBox = _buildSourcesBox(resultData.sources, 'research');
4326            }
4327            var findingsBox = chatRenderer.buildFindingsBox(resultData.raw_findings);
4328            var cleanResult = resultData.result;
4329            // Build DOM directly to avoid double-processing through addMessage
4330            chatRenderer.hideWelcomeScreen();
4331            var _box = document.getElementById('chat-history');
4332            if (_box) {
4333              var _wrap = document.createElement('div');
4334              _wrap.className = 'msg msg-ai';
4335              _wrap.dataset.researchSession = sessionId;
4336              var _role = document.createElement('div');
4337              _role.className = 'role';
4338              var _meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; });
4339              _role.textContent = _shortModel(_meta?.model);
4340              _applyModelColor(_role, _meta?.model);
4341              _role.appendChild(chatRenderer.roleTimestamp());
4342              var _body = document.createElement('div');
4343              _body.className = 'body';
4344              _body.innerHTML = srcBox + markdownModule.processWithThinking(
4345                markdownModule.squashOutsideCode(cleanResult)
4346              ) + findingsBox;
4347              _wrap.dataset.raw = cleanResult;
4348              _wrap.appendChild(_role);
4349              _wrap.appendChild(_body);
4350              _wrap.appendChild(chatRenderer.createMsgFooter(_wrap));
4351              _appendViewReportLink(_wrap, sessionId);
4352              _box.appendChild(_wrap);
4353              if (window.hljs) _wrap.querySelectorAll('pre code').forEach(function(b) { window.hljs.highlightElement(b); });
4354              uiModule.scrollHistory();
4355            }
4356          }
4357        }
4358        return;
4359      }
4360
4361      if (data.status !== 'running') return;
4362
4363      // Don't show reconnect UI if we've already switched away
4364      if (sessionModule.getCurrentSessionId() !== sessionId) return;
4365
4366      // Research is still running — show reconnect UI with spinner
4367      const box = document.getElementById('chat-history');
4368      if (!box) return;
4369
4370      const holder = document.createElement('div');
4371      holder.className = 'msg msg-ai research-reconnect';
4372      holder.dataset.researchSession = sessionId;
4373      const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
4374      const agentMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
4375      const agentModelLabel = _shortModel(agentMeta?.model);
4376      holder.innerHTML = `<div class="role">
4376${uiModule.esc(agentModelLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
4377      _applyModelColor(holder.querySelector('.role'), agentMeta?.model);
4378      box.appendChild(holder);
4379
4380      const bodyDiv = holder.querySelector('.body');
4381      const spinner = spinnerModule.create('Reconnecting to research...', 'right');
4382      bodyDiv.appendChild(spinner.createElement());
4383      spinner.start();
4384
4385      // Update spinner with current progress if available
4386      function updateSpinnerFromProgress(progress) {
4387        if (!progress || !progress.phase) return;
4388        const rp = progress;
4389        if (rp.phase === 'probing') {
4390          spinner.updateMessage(`Verifying model: ${rp.model || '?'}`);
4391        } else if (rp.phase === 'planning') {
4392          spinner.updateMessage('Analyzing question & planning research strategy');
4393        } else if (rp.phase === 'searching') {
4394          const q = rp.queries ? `${rp.queries} queries` : '';
4395          const s = rp.total_sources ? ` · ${rp.total_sources} sources` : '';
4396          spinner.updateMessage(`Round ${rp.round || '?'}: Searching${q ? ' (' + q + ')' : ''}${s}`);
4397        } else if (rp.phase === 'reading') {
4398          spinner.updateMessage(rp.title ? `Reading: ${rp.title}` : `Round ${rp.round || '?'}: Reading ${rp.new_sources || ''} pages · ${rp.total_sources || 0} sources total`);
4399        } else if (rp.phase === 'analyzing') {
4400          spinner.updateMessage(`Round ${rp.round || '?'}: Analyzing ${rp.total_findings || 0} findings`);
4401        } else if (rp.phase === 'writing') {
4402          spinner.updateMessage(`Writing report · ${rp.total_sources || 0} sources`);
4403        }
4404      }
4405
4406      updateSpinnerFromProgress(data.progress);
4407      _researchingStreamIds.add(sessionId);
4408      if (sessionModule && sessionModule.markResearching) sessionModule.markResearching(sessionId);
4409
4410      // Restore research timer from started_at
4411      if (data.started_at && spinner && spinner.element) {
4412        _researchStartTime = data.started_at * 1000;
4413        _researchAvgDuration = data.avg_duration || null;
4414        _researchTimerEl = document.createElement('div');
4415        _researchTimerEl.className = 'research-timer';
4416        _researchTimerEl.style.cssText = 'font-size:0.8em; opacity:0.6; margin-top:4px; font-family:monospace;';
4417        spinner.element.parentNode.insertBefore(_researchTimerEl, spinner.element.nextSibling);
4418        _researchTimerInterval = setInterval(() => {
4419          if (!_researchTimerEl) return;
4420          var elapsed = Math.floor((Date.now() - _researchStartTime) / 1000);
4421          var mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
4422          var ss = String(elapsed % 60).padStart(2, '0');
4423          var txt = mm + ':' + ss;
4424          if (_researchAvgDuration) {
4425            var avgM = String(Math.floor(_researchAvgDuration / 60)).padStart(2, '0');
4426            var avgS = String(Math.round(_researchAvgDuration % 60)).padStart(2, '0');
4427            txt += ' / avg ' + avgM + ':' + avgS;
4428          }
4429          _researchTimerEl.textContent = txt;
4430        }, 1000);
4431        // Reconnect synapse — seed it with whatever progress is already known
4432        try {
4433          _researchSynapse = createResearchSynapse(spinner.element.parentNode, {
4434            query: data.query || '',
4435            startedAt: _researchStartTime,
4436          });
4437          if (_researchSynapse.element && _researchTimerEl) {
4438            spinner.element.parentNode.insertBefore(_researchSynapse.element, _researchTimerEl);
4439          }
4440          if (data.progress) {
4441            _researchSynapse.setPhase(data.progress.phase, data.progress);
4442            if (typeof data.progress.round === 'number') _researchSynapse.setRound(data.progress.round);
4443            if (typeof data.progress.total_sources === 'number') _researchSynapse.setSourceCount(data.progress.total_sources);
4444          }
4445        } catch (e) { console.warn('synapse reconnect failed', e); }
4446      }
4447
4448      // Poll for completion
4449      const pollInterval = setInterval(async () => {
4450        // Stop polling if user switched to a different session
4451        if (sessionModule.getCurrentSessionId() !== sessionId) {
4452          clearInterval(pollInterval);
4453          spinner.destroy();
4454          _clearResearchTimer();
4455          if (holder.parentNode) holder.remove();
4456          _researchingStreamIds.delete(sessionId);
4457          if (_researchingStreamIds.size === 0) {
4458            var _rToggleP = document.getElementById('research-toggle-btn');
4459            if (_rToggleP) _rToggleP.classList.remove('research-running');
4460          }
4461          return;
4462        }
4463        try {
4464          const pollRes = await fetch(`${API_BASE}/api/research/status/${sessionId}`);
4465          if (!pollRes.ok) {
4466            clearInterval(pollInterval);
4467            spinner.destroy();
4468            _clearResearchTimer();
4469            _researchingStreamIds.delete(sessionId);
4470            if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId);
4471            return;
4472          }
4473          const pollData = await pollRes.json();
4474          updateSpinnerFromProgress(pollData.progress);
4475          if (_researchSynapse && pollData.progress) {
4476            _researchSynapse.setPhase(pollData.progress.phase, pollData.progress);
4477            if (typeof pollData.progress.round === 'number') _researchSynapse.setRound(pollData.progress.round);
4478            if (typeof pollData.progress.total_sources === 'number') _researchSynapse.setSourceCount(pollData.progress.total_sources);
4479          }
4480
4481          if (pollData.status !== 'running') {
4482            clearInterval(pollInterval);
4483            spinner.destroy();
4484            _clearResearchTimer();
4485            _researchingStreamIds.delete(sessionId);
4486            if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId);
4487
4488            if (pollData.status === 'done') {
4489              _notifyResearchComplete(sessionId, data.query || '');
4490              const rRes = await fetch(`${API_BASE}/api/research/result/${sessionId}`, { method: 'POST' });
4491              if (rRes.ok) {
4492                const rData = await rRes.json();
4493                if (rData.result) {
4494                  var srcHtml = '';
4495                  if (rData.sources && rData.sources.length > 0) {
4496                    srcHtml = _buildSourcesBox(rData.sources, 'research');
4497                  }
4498                  var findingsHtml = chatRenderer.buildFindingsBox(rData.raw_findings);
4499                  bodyDiv.innerHTML = srcHtml + markdownModule.processWithThinking(
4500                    markdownModule.squashOutsideCode(rData.result)
4501                  ) + findingsHtml;
4502                  holder.dataset.raw = rData.result;
4503                  _appendViewReportLink(holder, sessionId);
4504                  if (window.hljs) {
4505                    holder.querySelectorAll('pre code').forEach(b => window.hljs.highlightElement(b));
4506                  }
4507                }
4508              }
4509            } else {
4510              bodyDiv.innerHTML = '<i style="color: var(--color-error);">[Research ' + pollData.status + ']</i>';
4511            }
4512          }
4513        } catch (e) {
4514          console.error('Research poll error:', e);
4515        }
4516      }, 2000);
4517    } catch (e) {
4518      // No research pending, that's fine
4519    }
4520  }
4521
4522  /** Set a display override for the next user message bubble */
4523  export function setDisplayOverride(text) {
4524    _displayOverride = text;
4525  }
4526
4527  /** Hide the user bubble for the next submit (e.g. continue after stop) */
4528  export function setHideUserBubble() {
4529    _hideUserBubble = true;
4530  }
4531
4532  /** Set the AI element to merge with the next streamed response (continue after stop) */
4533  export function setPendingContinue(el) {
4534    _pendingContinue = el;
4535  }
4536
4537  /**
4538   * Delete an AI message and its preceding user message from the conversation.
4539   */
4540  export async function deleteMessage(msgElement) {
4541    const box = document.getElementById('chat-history');
4542    const allMsgs = Array.from(box.querySelectorAll('.msg'));
4543    const clickedIndex = allMsgs.indexOf(msgElement);
4544    if (clickedIndex < 0) return;
4545
4546    // No early-out on a missing session: an output shown before any model was
4547    // selected (issue #1428) has no session/persisted rows, but its "x" must
4548    // still remove it. We only need the session id for the server-side delete
4549    // below; without one we fall back to removing the DOM.
4550    const sessionId = sessionModule.getCurrentSessionId();
4551
4552    const clickedIsUser = msgElement.classList.contains('msg-user');
4553
4554    // Find the user+AI pair
4555    let userIndex = -1;
4556    let aiIndex = -1;
4557    if (clickedIsUser) {
4558      userIndex = clickedIndex;
4559      // Find the following AI message
4560      for (let i = clickedIndex + 1; i < allMsgs.length; i++) {
4561        if (allMsgs[i].classList.contains('msg-ai') && !allMsgs[i].classList.contains('msg-continuation')) {
4562          aiIndex = i;
4563          break;
4564        }
4565        if (allMsgs[i].classList.contains('msg-user')) break; // next user msg, no AI response
4566      }
4567    } else {
4568      // If clicked on a continuation, walk back to the main AI message
4569      let mainAiIndex = clickedIndex;
4570      if (allMsgs[mainAiIndex].classList.contains('msg-continuation')) {
4571        for (let i = mainAiIndex - 1; i >= 0; i--) {
4572          if (allMsgs[i].classList.contains('msg-ai') && !allMsgs[i].classList.contains('msg-continuation')) {
4573            mainAiIndex = i;
4574            break;
4575          }
4576        }
4577      }
4578      aiIndex = mainAiIndex;
4579      // Find the preceding user message
4580      for (let i = aiIndex - 1; i >= 0; i--) {
4581        if (allMsgs[i].classList.contains('msg-user')) {
4582          userIndex = i;
4583          break;
4584        }
4585      }
4586    }
4587
4588    // Collect DB message IDs and DOM elements to remove
4589    const msgIds = [];
4590    const domToRemove = [];
4591
4592    // Add the user message if found
4593    if (userIndex >= 0) {
4594      domToRemove.push(allMsgs[userIndex]);
4595      const uid = allMsgs[userIndex].dataset.dbId;
4596      if (uid) msgIds.push(uid);
4597    }
4598
4599    // Add the AI message if found
4600    if (aiIndex >= 0) {
4601      domToRemove.push(allMsgs[aiIndex]);
4602      const aid = allMsgs[aiIndex].dataset.dbId;
4603      if (aid) msgIds.push(aid);
4604
4605      const aiEl = allMsgs[aiIndex];
4606      // Also remove agent-thread elements BETWEEN user and AI
4607      if (userIndex >= 0) {
4608        let between = allMsgs[userIndex].nextElementSibling;
4609        while (between && between !== aiEl) {
4610          domToRemove.push(between);
4611          between = between.nextElementSibling;
4612        }
4613      }
4614      // Walk forward from the AI element to remove continuations and tool bubbles
4615      let sibling = aiEl.nextElementSibling;
4616      while (sibling) {
4617        if (sibling.classList.contains('msg-user') ||
4618            (sibling.classList.contains('msg-ai') && !sibling.classList.contains('msg-continuation'))) {
4619          break;
4620        }
4621        domToRemove.push(sibling);
4622        sibling = sibling.nextElementSibling;
4623      }
4624    }
4625
4626    if (!msgIds.length || !sessionId) {
4627      // No persisted rows to delete (no DB IDs, or no session at all — e.g. an
4628      // error output shown before a model was selected, #1428). Just remove the
4629      // DOM so the "x" works regardless.
4630      domToRemove.forEach(el => el.remove());
4631      if (uiModule) uiModule.showToast('Message deleted');
4632      return;
4633    }
4634
4635    try {
4636      const res = await fetch(`${API_BASE}/api/session/${sessionId}/delete-messages`, {
4637        method: 'POST',
4638        headers: { 'Content-Type': 'application/json' },
4639        body: JSON.stringify({ msg_ids: msgIds })
4640      });
4641      if (!res.ok) throw new Error('Server error ' + res.status);
4642      domToRemove.forEach(el => el.remove());
4643      if (uiModule) uiModule.showToast('Message deleted');
4644    } catch (err) {
4645      console.error('Delete failed:', err);
4646      if (uiModule) uiModule.showError('Delete failed: ' + err.message);
4647    }
4648  }
4649
4650  /**
4651   * Edit an AI message inline. Makes the body contentEditable, saves to DB on confirm.
4652   */
4653  export async function editAIMessage(msgElement) {
4654    const body = msgElement.querySelector('.body');
4655    if (!body) return;
4656
4657    const isEditing = body.contentEditable === 'true' || body.contentEditable === 'plaintext-only';
4658    if (isEditing) return; // already editing
4659
4660    const originalRaw = msgElement.dataset.raw || body.textContent || '';
4661
4662    // Create editable textarea overlay
4663    const textarea = document.createElement('textarea');
4664    textarea.className = 'msg-edit-textarea';
4665    textarea.value = originalRaw;
4666    textarea.style.width = '100%';
4667    textarea.style.minHeight = Math.max(100, body.offsetHeight) + 'px';
4668    body.style.display = 'none';
4669    body.parentNode.insertBefore(textarea, body.nextSibling);
4670    textarea.focus();
4671
4672    // Add save/cancel bar
4673    const bar = document.createElement('div');
4674    bar.className = 'msg-edit-bar';
4675    const saveBtn = document.createElement('button');
4676    saveBtn.className = 'msg-edit-save';
4677    saveBtn.textContent = 'Save';
4678    const cancelBtn = document.createElement('button');
4679    cancelBtn.className = 'msg-edit-cancel';
4680    cancelBtn.textContent = 'Cancel';
4681    bar.appendChild(saveBtn);
4682    bar.appendChild(cancelBtn);
4683    textarea.parentNode.insertBefore(bar, textarea.nextSibling);
4684
4685    function cleanup() {
4686      textarea.remove();
4687      bar.remove();
4688      body.style.display = '';
4689    }
4690
4691    cancelBtn.addEventListener('click', (e) => {
4692      e.stopPropagation();
4693      cleanup();
4694    });
4695
4696    saveBtn.addEventListener('click', async (e) => {
4697      e.stopPropagation();
4698      const newContent = textarea.value;
4699      if (newContent === originalRaw) { cleanup(); return; }
4700
4701      const msgId = msgElement.dataset.dbId;
4702      if (!msgId) { if (uiModule) uiModule.showError('Cannot edit: message ID not found'); cleanup(); return; }
4703
4704      const sessionId = sessionModule.getCurrentSessionId();
4705      if (!sessionId) { cleanup(); return; }
4706
4707      try {
4708        const res = await fetch(`${API_BASE}/api/session/${sessionId}/edit-message`, {
4709          method: 'POST',
4710          headers: { 'Content-Type': 'application/json' },
4711          body: JSON.stringify({ msg_id: msgId, content: newContent }),
4712        });
4713        if (!res.ok) throw new Error('Server error ' + res.status);
4714
4715        // Re-render body with markdown
4716        body.innerHTML = markdownModule.processWithThinking(markdownModule.squashOutsideCode(newContent));
4717        msgElement.dataset.raw = newContent;
4718
4719        // Add edited indicator if not already present
4720        if (!msgElement.querySelector('.edited-indicator')) {
4721          const indicator = document.createElement('div');
4722          indicator.className = 'edited-indicator';
4723          indicator.textContent = '[Message edited]';
4724          body.parentNode.insertBefore(indicator, body.nextSibling);
4725        }
4726
4727        cleanup();
4728        if (uiModule) uiModule.showToast('Message edited');
4729      } catch (err) {
4730        console.error('Edit failed:', err);
4731        if (uiModule) uiModule.showError('Edit failed: ' + err.message);
4732      }
4733    });
4734  }
4735
4736  /**
4737   * Rewrite the AI's last response with a specific instruction.
4738   * Uses the lightweight /api/rewrite endpoint — no tools, no agent loop.
4739   * Just rewrites the text of the last AI bubble.
4740   */
4741  export async function rewriteWith(aiMsgElement, instruction) {
4742    const sessionId = sessionModule.getCurrentSessionId();
4743    if (!sessionId) return;
4744
4745    // Get the original text from the AI bubble
4746    const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
4747    const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
4748
4749    if (!oldRaw.trim()) {
4750      if (uiModule) uiModule.showError('No text to rewrite');
4751      return;
4752    }
4753
4754    // Save current response as a variant
4755    let variants = [];
4756    try { variants = JSON.parse(aiMsgElement.dataset.variants || '[]'); } catch(_) {}
4757    if (variants.length === 0) {
4758      variants.push({ raw: oldRaw, html: oldHtml, label: 'original' });
4759    }
4760
4761    // Determine label from instruction
4762    let varLabel = 'rewrite';
4763    if (instruction.includes('shorter')) varLabel = 'shorter';
4764    else if (instruction.includes('simpler')) varLabel = 'simpler';
4765
4766    // Clear the bubble and show a whirlpool spinner while we wait for the
4767    // rewrite (replaces the old "Rewriting..." text).
4768    const bodyEl = aiMsgElement.querySelector('.body');
4769    let _rwSpin = null;
4770    if (bodyEl) {
4771      bodyEl.innerHTML = '';
4772      _rwSpin = spinnerModule.createWhirlpool(18);
4773      _rwSpin.element.style.margin = '4px 0';
4774      bodyEl.appendChild(_rwSpin.element);
4775    }
4776    // Stop + detach the spinner (called once real content starts rendering, and
4777    // on the failure path so it never spins forever).
4778    const _killRwSpin = () => { if (_rwSpin) { try { _rwSpin.destroy(); } catch (_) {} _rwSpin = null; } };
4779
4780    try {
4781      const res = await fetch(`${API_BASE}/api/rewrite`, {
4782        method: 'POST',
4783        headers: { 'Content-Type': 'application/json' },
4784        body: JSON.stringify({
4785          session_id: sessionId,
4786          original_text: oldRaw,
4787          instruction: instruction,
4788        }),
4789      });
4790
4791      if (!res.ok) {
4792        throw new Error(`HTTP ${res.status}`);
4793      }
4794
4795      const reader = res.body.getReader();
4796      const decoder = new TextDecoder();
4797      let buffer = '';
4798      let newText = '';
4799
4800      while (true) {
4801        const { done, value } = await reader.read();
4802        if (done) break;
4803        buffer += decoder.decode(value, { stream: true });
4804        const lines = buffer.split('\n');
4805        buffer = lines.pop() || '';
4806
4807        for (const line of lines) {
4808          if (!line.startsWith('data: ')) continue;
4809          const payload = line.slice(6).trim();
4810          if (payload === '[DONE]') continue;
4811          try {
4812            const data = JSON.parse(payload);
4813            // The endpoint streams `event: error\ndata: {error,status}` on
4814            // failure — surface it instead of silently hanging on "Rewriting…".
4815            if (data.error) {
4816              throw new Error(data.error || ('HTTP ' + (data.status || 500)));
4817            }
4818            // Reasoning tokens (vLLM --reasoning-parser: Qwen3 / DeepSeek-R1)
4819            // arrive as separate {delta, thinking:true} chunks. They are NOT
4820            // the rewrite — fold them away so they don't pollute the result.
4821            if (data.thinking) continue;
4822            if (data.delta) {
4823              newText += data.delta;
4824              _killRwSpin();
4825              if (bodyEl) {
4826                bodyEl.innerHTML = markdownModule.processWithThinking(
4827                  markdownModule.squashOutsideCode(newText)
4828                );
4829              }
4830            }
4831          } catch (e) {
4832            if (e instanceof Error && e.message) throw e;  // re-throw real errors
4833            /* ignore JSON parse noise */
4834          }
4835        }
4836      }
4837
4838      // Strip any thinking markup from the answer. A reasoning model may emit
4839      // an inline <think>…</think> block, a bare </think> (no opener), or — when
4840      // its reasoning came via reasoning_content — a stray leading <think> that
4841      // never closes (so it would otherwise hide the whole answer). Peel all of
4842      // those off so what's left is just the rewritten text.
4843      const _stripThink = (t) => {
4844        t = markdownModule.normalizeThinkingMarkup(t || '');
4845        t = t.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>[\s\S]*?<\/(?:think(?:ing)?|thought)>/gi, '');   // complete blocks
4846        if (/<\/(?:think(?:ing)?|thought)>/i.test(t)) t = t.replace(/^[\s\S]*?<\/(?:think(?:ing)?|thought)>/i, '');  // reasoning w/o opener
4847        return t.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '').trim();        // any orphan tag
4848      };
4849      newText = _stripThink(newText);
4850
4851      // Nothing left after stripping (or an empty stream) → real failure, not a
4852      // blank bubble.
4853      if (!newText.trim()) {
4854        throw new Error('model returned no rewritten text');
4855      }
4856
4857      // Update the element's raw text
4858      if (newText) {
4859        aiMsgElement.dataset.raw = newText;
4860        // Final render with proper markdown
4861        if (bodyEl) {
4862          bodyEl.innerHTML = markdownModule.processWithThinking(
4863            markdownModule.squashOutsideCode(newText)
4864          );
4865        }
4866
4867        // Save the new response as a variant
4868        variants.push({ raw: newText, html: bodyEl ? bodyEl.innerHTML : '', label: varLabel });
4869        aiMsgElement.dataset.variants = JSON.stringify(variants);
4870        aiMsgElement.dataset.variantIndex = String(variants.length - 1);
4871
4872        // Persist variant metadata to server
4873        try {
4874          await fetch(`${API_BASE}/api/session/${sessionId}/update-last-meta`, {
4875            method: 'POST',
4876            headers: { 'Content-Type': 'application/json' },
4877            body: JSON.stringify({ metadata: { variants: variants, variantIndex: variants.length - 1 } }),
4878          });
4879        } catch (_) {}
4880
4881        // Re-render variant navigation
4882        _renderVariantNav(aiMsgElement, variants, variants.length - 1);
4883      }
4884
4885      if (uiModule) uiModule.scrollHistory();
4886
4887    } catch (err) {
4888      console.error('Rewrite failed:', err);
4889      _killRwSpin();
4890      // Restore original content on failure
4891      if (bodyEl) bodyEl.innerHTML = oldHtml;
4892      if (uiModule) uiModule.showError('Rewrite failed: ' + err.message);
4893    }
4894  }
4895
4896  /**
4897   * Continue the AI's response from where it left off.
4898   */
4899  export async function continueFrom(aiMsgElement) {
4900    const sessionId = sessionModule.getCurrentSessionId();
4901    if (!sessionId) return;
4902
4903    const messageInput = uiModule.el('message');
4904    if (messageInput) {
4905      messageInput.value = 'Continue from where you left off.';
4906      const submitBtn = document.querySelector('.send-btn');
4907      if (submitBtn) submitBtn.click();
4908    }
4909  }
4910
4911  // Open a chat attachment in the right place: images → Gallery editor; PDFs &
4912  // text/code/markdown → Documents viewer; anything else → raw file. A given
4913  // upload's imported document is reused (cached by upload id) so clicking it
4914  // again re-opens the same doc instead of making duplicates.
4915  const _attachDocCache = new Map();  // upload id -> doc id
4916  function _attachLang(name) {
4917    const m = (name || '').toLowerCase().match(/\.([a-z0-9]+)$/);
4918    const ext = m ? m[1] : '';
4919    const map = { md:'markdown', markdown:'markdown', js:'javascript', ts:'typescript',
4920      jsx:'javascript', tsx:'typescript', py:'python', rb:'ruby', go:'go', rs:'rust',
4921      java:'java', c:'c', cpp:'cpp', h:'c', hpp:'cpp', cs:'csharp', php:'php', html:'html',
4922      htm:'html', css:'css', scss:'scss', json:'json', yaml:'yaml', yml:'yaml', sh:'bash',
4923      bash:'bash', sql:'sql', csv:'csv', xml:'xml' };
4924    return map[ext] || '';
4925  }
4926  async function openAttachment(att, isImage) {
4927    if (!att || !att.id) return;
4928    const id = att.id, name = att.name || '', mime = att.mime || '';
4929    const url = `${API_BASE}/api/upload/${id}`;
4930
4931    // Images → Gallery editor.
4932    if (isImage) {
4933      try {
4934        const gx = await import('./galleryEditor.js');
4935        if (gx.openEditor) { gx.openEditor(url, id, null, name); return; }
4936      } catch (e) { console.warn('gallery open failed', e); }
4937      window.open(url, '_blank');
4938      return;
4939    }
4940
4941    const isPdf = mime === 'application/pdf' || /\.pdf$/i.test(name);
4942    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;
4943    const isTextDoc = TEXT_EXT.test(name) || /^text\//.test(mime);
4944    if (!isPdf && !isTextDoc) { window.open(url, '_blank'); return; }  // binary/unknown → raw
4945
4946    // Reuse the doc we already imported for this upload, if it still loads.
4947    const cached = _attachDocCache.get(id);
4948    if (cached) {
4949      try {
4950        documentModule.openPanel && documentModule.openPanel();
4951        await documentModule.loadDocument(cached);
4952        return;
4953      } catch (_) { _attachDocCache.delete(id); }
4954    }
4955
4956    // Need a session to attach the doc to (bare-session fallback, same as compose).
4957    let sid = '';
4958    try { sid = sessionModule.getCurrentSessionId() || ''; } catch (_) {}
4959    if (!sid) {
4960      try {
4961        const _fd = new FormData();
4962        _fd.append('name', name || 'Attachment');
4963        _fd.append('skip_validation', 'true');
4964        const r = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' });
4965        if (r.ok) { const d = await r.json(); if (d && d.id) { sid = d.id; if (sessionModule.loadSessions) await sessionModule.loadSessions(); } }
4966      } catch (_) {}
4967    }
4968
4969    try {
4970      let doc;
4971      if (isPdf) {
4972        // import-pdf wants a fresh file upload — re-fetch the stored blob and post it.
4973        const blob = await (await fetch(url)).blob();
4974        const fd = new FormData();
4975        fd.append('file', blob, name || 'document.pdf');
4976        if (sid) fd.append('session_id', sid);
4977        const res = await fetch(`${API_BASE}/api/documents/import-pdf`, { method: 'POST', body: fd, credentials: 'same-origin' });
4978        if (!res.ok) throw new Error('import-pdf ' + res.status);
4979        doc = await res.json();
4980      } else {
4981        const text = await (await fetch(url)).text();
4982        const res = await fetch(`${API_BASE}/api/document`, {
4983          method: 'POST', headers: { 'Content-Type': 'application/json' },
4984          body: JSON.stringify({ session_id: sid || null, title: name.replace(/\.[^.]+$/, '') || 'Document', content: text, language: _attachLang(name) }),
4985        });
4986        if (!res.ok) throw new Error('document ' + res.status);
4987        doc = await res.json();
4988      }
4989      if (doc && doc.id) {
4990        _attachDocCache.set(id, doc.id);
4991        documentModule.openPanel && documentModule.openPanel();
4992        if (documentModule.injectFreshDoc) documentModule.injectFreshDoc(doc);
4993        else await documentModule.loadDocument(doc.id);
4994      }
4995    } catch (e) {
4996      console.error('open attachment as document failed', e);
4997      import('./ui.js').then(m => m.showError && m.showError('Could not open attachment')).catch(() => {});
4998      window.open(url, '_blank');  // fallback so the file is still reachable
4999    }
5000  }
5001
5002  // Public API
5003  const chatModule = {
5004    init,
5005    initListeners,
5006    openAttachment,
5007    addMessage: chatRenderer.addMessage,
5008    displayMetrics: chatRenderer.displayMetrics,
5009    handleChatSubmit,
5010    abortCurrentRequest,
5011    detachCurrentStream,
5012    checkBackgroundStream,
5013    resumeStream,
5014    hideWelcomeScreen: chatRenderer.hideWelcomeScreen,
5015    showWelcomeScreen: chatRenderer.showWelcomeScreen,
5016    checkPendingResearch,
5017    getImageCost: chatRenderer.getImageCost,
5018    setDisplayOverride,
5019    setHideUserBubble,
5020    setPendingContinue,
5021    regenerateFrom,
5022    forkFrom,
5023    editUserMessage,
5024    editAIMessage,
5025    resendUserMessage,
5026    deleteMessage,
5027    rewriteWith,
5028    continueFrom,
5029    _appendViewReportLink,
5030    hasActiveStream,
5031  };
5032
5033  // Single delegated handler for tool-call fold/expand. One listener on
5034  // document.body covers every .agent-thread-node — running, completed,
5035  // streaming, history-rendered, compare-mode, all of them. Re-attaching
5036  // per-node listeners on every innerHTML rewrite was the source of the
5037  // "needs many clicks" bug.
5038  if (!window.__odysseus_thread_click_bound) {
5039    document.body.addEventListener('click', (e) => {
5040      const header = e.target.closest('.agent-thread-header');
5041      if (!header) return;
5042      const node = header.closest('.agent-thread-node');
5043      if (!node) return;
5044      node.classList.toggle('open');
5045    });
5046    window.__odysseus_thread_click_bound = true;
5047  }
5048
5049  export default chatModule;
5050  window.chatModule = chatModule;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.