/* chat-ui.js — auto-generated by scripts/build_chat_ui.py
 * Source: static/js/chat-ui/
 * Do not edit this file directly; edit the source modules and re-run the script.
 */
(function () {
'use strict';

// ── debug.js ────────────────────────────────────────────────────

const _getDebugSetting = () => {
    try { return localStorage.getItem('debug') || ''; } catch (e) { return ''; }
};

function _isEnabled(ns) {
    const setting = _getDebugSetting();
    if (!setting) return false;
    if (setting === '*' || setting === 'chat-ui:*') return true;
    return setting.split(',').some(s => {
        const t = s.trim();
        return t === 'chat-ui:' + ns || t === ns || t === 'chat-ui:*';
    });
}

function log(ns) {
    const prefix = `[chat-ui:${ns}]`;
    return {
        debug: (...args) => { if (_isEnabled(ns)) console.debug(prefix, ...args); },
        info:  (...args) => { if (_isEnabled(ns)) console.info(prefix, ...args); },
        warn:  (...args) => console.warn(prefix, ...args),
        error: (...args) => console.error(prefix, ...args),
    };
}

const DEV_MODE = !!_getDebugSetting();

function assert(cond, msg, context) {
    if (!cond) {
        log('assert').error('ASSERTION FAILED:', msg, context);
        if (DEV_MODE) throw new Error(`chat-ui assert: ${msg}`);
    }
}

function installDiagnostic(ui, activeTurns, eventLog) {
    if (!DEV_MODE) return;
    window.__chatui = {
        ui,
        turns: () => [...activeTurns.values()].map(t => ({
            id: t.id,
            phase: t.phase,
            anchor: t.$anchor && t.$anchor[0],
        })),
        history: () => [...eventLog],
        dump: () => {
            const rows = [...activeTurns.values()].map(t => ({
                id: t.id,
                phase: t.phase,
                seq: t._lastSeq,
            }));
            console.table(rows);
        },
    };
    log('debug').info('window.__chatui diagnostic installed');
}

// ── renderers.js ────────────────────────────────────────────────

// ── Sanitizer ─────────────────────────────────────────────────────────────────

const ALLOWED_TAGS = new Set([
    'a','b','i','em','strong','code','pre','blockquote',
    'ul','ol','li','p','br','hr','h1','h2','h3','h4','h5','h6',
    'table','thead','tbody','tr','th','td','span','div',
]);
const ALLOWED_ATTRS = {
    a:    ['href', 'title', 'target'],
    code: ['class'],
    pre:  ['class'],
};

function _walkSanitize(node) {
    const children = [...node.childNodes];
    for (const child of children) {
        if (child.nodeType === Node.ELEMENT_NODE) {
            const tag = child.tagName.toLowerCase();
            if (!ALLOWED_TAGS.has(tag)) {
                // Replace with its children (keep text content)
                while (child.firstChild) node.insertBefore(child.firstChild, child);
                node.removeChild(child);
                continue;
            }
            // Strip disallowed attributes
            const allowed = (ALLOWED_ATTRS[tag] || []).concat(ALLOWED_ATTRS['*'] || []);
            const attrsToRemove = [...child.attributes].filter(a => !allowed.includes(a.name));
            attrsToRemove.forEach(a => child.removeAttribute(a.name));
            // Sanitize hrefs
            if (tag === 'a') {
                const href = child.getAttribute('href') || '';
                if (/^javascript:/i.test(href) || /^data:/i.test(href)) {
                    child.setAttribute('href', '#');
                }
                child.setAttribute('rel', 'noopener noreferrer');
            }
            _walkSanitize(child);
        }
    }
}

function sanitize(html) {
    const tpl = document.createElement('template');
    tpl.innerHTML = html;
    _walkSanitize(tpl.content);
    return tpl.innerHTML;
}

// ── Escaping ──────────────────────────────────────────────────────────────────

function escape(text) {
    const div = document.createElement('div');
    div.textContent = String(text == null ? '' : text);
    return div.innerHTML;
}

function truncateLine(text, max) {
    const first = (text || '').split('\n')[0].trim();
    return first.length > max ? first.slice(0, max) + '\u2026' : first;
}

// ── Syntax highlighters ───────────────────────────────────────────────────────

function highlightPython(code) {
    if (!code) return '';
    const patterns = [
        { type: 'fstring', regex: /f"(?:[^"\\]|\\.)*"/g },
        { type: 'fstring', regex: /f'(?:[^'\\]|\\.)*'/g },
        { type: 'string', regex: /"""(?:[^"]|\\.)*"""/g },
        { type: 'string', regex: /'''(?:[^']|\\.)*'''/g },
        { type: 'string', regex: /"(?:[^"\\]|\\.)*"/g },
        { type: 'string', regex: /'(?:[^'\\]|\\.)*'/g },
        { type: 'comment', regex: /#.*$/gm },
        { type: 'decorator', regex: /@\w+(?:\.\w+)*/g },
        { type: 'builtin', regex: /\b(print|len|range|list|dict|str|int|float|type|isinstance|enumerate|zip|map|filter|sorted|reversed|open|input|super|set|tuple|abs|max|min|sum|round|any|all|hasattr|getattr|setattr|delattr|callable|repr|format|hash|id|dir|vars|help|slice|staticmethod|classmethod|property|issubclass|iter|next|bin|oct|hex|chr|ord|pow|divmod|compile|eval|exec|globals|locals|breakpoint|memoryview|frozenset|complex|ascii)\b/g },
        { type: 'keyword', regex: /\b(def|class|if|elif|else|for|while|return|import|from|as|try|except|finally|with|raise|pass|break|continue|lambda|yield|assert|del|global|nonlocal|async|await)\b/g },
        { type: 'boolean', regex: /\b(True|False)\b/g },
        { type: 'none', regex: /\bNone\b/g },
        { type: 'self', regex: /\b(self|cls)\b/g },
        { type: 'number', regex: /\b(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|\d+\.?\d*(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)\b/g },
        { type: 'function', regex: /\b([a-zA-Z_]\w*)\s*(?=\()/g },
        { type: 'operator', regex: /(?:==|!=|<=|>=|<<|>>|\*\*|\/\/|&&|\|\||[+\-*/%=<>!&|^~])/g },
    ];
    const matches = [];
    for (const p of patterns) {
        const re = new RegExp(p.regex.source, p.regex.flags);
        let m;
        while ((m = re.exec(code)) !== null) {
            matches.push({ type: p.type, start: m.index, end: m.index + m[0].length, text: m[0] });
            if (m[0].length === 0) re.lastIndex++;
        }
    }
    matches.sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start));
    const filtered = [];
    let lastEnd = -1;
    for (const m of matches) {
        if (m.start >= lastEnd) { filtered.push(m); lastEnd = m.end; }
    }
    let result = '', pos = 0;
    for (const m of filtered) {
        if (m.start > pos) result += escape(code.slice(pos, m.start));
        result += `<span class="hl-${m.type}">${escape(m.text)}</span>`;
        pos = m.end;
    }
    if (pos < code.length) result += escape(code.slice(pos));
    return result;
}

function highlightDiff(patch) {
    if (!patch) return '';
    return escape(patch).split('\n').map(line => {
        if (line.startsWith('@@'))   return `<span class="hl-diff-header">${line}</span>`;
        if (line.startsWith('--- ') || line.startsWith('+++ ')) return `<span class="hl-diff-filename">${line}</span>`;
        if (line.startsWith('+'))   return `<span class="hl-diff-add">${line}</span>`;
        if (line.startsWith('-'))   return `<span class="hl-diff-remove">${line}</span>`;
        if (line.startsWith('\\')) return `<span class="hl-diff-meta">${line}</span>`;
        return `<span class="hl-diff-context">${line}</span>`;
    }).join('\n');
}

// ── Tool result rendering helpers ─────────────────────────────────────────────

function summarizeToolResult(result) {
    if (result === null || result === undefined) return 'OK';
    if (Array.isArray(result)) return `${result.length} item${result.length !== 1 ? 's' : ''}`;
    if (typeof result === 'object') {
        const keys = Object.keys(result);
        if (!keys.length) return 'OK';
        if ('status' in result) {
            const s = String(result.status);
            return ('message' in result && String(result.message).length < 100)
                ? `${s}: ${String(result.message)}` : s;
        }
        if ('message' in result && keys.length === 1) return String(result.message).slice(0, 120);
        if ('count'   in result && typeof result.count === 'number') return `${result.count} item${result.count !== 1 ? 's' : ''}`;
        const parts = [];
        for (const k of keys.slice(0, 3)) {
            const v = result[k];
            if (v !== null && v !== undefined && typeof v !== 'object') parts.push(`${k}: ${String(v)}`);
        }
        if (parts.length) return parts.join(' · ');
        return `${keys.length} field${keys.length !== 1 ? 's' : ''}`;
    }
    const s = String(result);
    return s.length > 120 ? s.slice(0, 117) + '...' : s;
}

function _renderRunpyResult(r) {
    const hasStdout = r.stdout && r.stdout.trim().length > 0;
    const hasStderr = r.stderr && r.stderr.trim().length > 0;
    const hasError  = r.exit_code !== 0;
    const statusColor = hasError ? 'text-red-600' : 'text-green-600';
    const statusBg    = hasError ? 'bg-red-50 border-red-200' : 'bg-green-50 border-green-200';
    const statusIcon  = hasError ? '&#10060;' : '&#9989;';
    const $wrap = $('<div>');
    const $badge = $(`<div class="flex items-center gap-2 mb-1.5 text-[10px] font-mono border rounded px-2 py-1">`).addClass(statusColor).addClass(statusBg);
    $badge.append(
        $('<span>').html(`${statusIcon} exit: ${escape(String(r.exit_code))}`),
        $('<span class="text-gray-400">').text('|'),
        $('<span>').text(`time: ${r.execution_time}s`)
    );
    if (r.available_helpers) $badge.append($('<span class="text-gray-400">').text('|'), $('<span class="text-gray-500">').text(Object.keys(r.available_helpers).length + ' helpers'));
    $wrap.append($badge);
    if (hasStdout) $wrap.append($('<div class="text-[10px] font-semibold text-gray-500 mb-0.5">').text('stdout'), $('<pre class="text-xs bg-gray-50 border border-gray-200 rounded p-2 overflow-x-auto font-mono text-gray-800 max-h-[200px]">').text(r.stdout));
    if (hasStderr) $wrap.append($('<div class="text-[10px] font-semibold text-red-500 mb-0.5 mt-1">').text('stderr'), $('<pre class="text-xs bg-red-50 border border-red-200 rounded p-2 overflow-x-auto font-mono text-red-700 max-h-[200px]">').text(r.stderr));
    if (!hasStdout && !hasStderr) $wrap.append($('<div class="text-xs text-gray-400 italic">').text('No output'));
    return $wrap;
}

function _renderBashResult(r) {
    let stdout = r.stdout, stderr = r.stderr;
    if (r.data && !stdout && !stderr) {
        try { const p = JSON.parse(r.data); stdout = p.stdout || ''; stderr = p.stderr || ''; } catch(e) {}
    }
    const hasStdout = stdout && stdout.trim().length > 0;
    const hasStderr = stderr && stderr.trim().length > 0;
    const hasError  = r.exit_code !== 0;
    const statusColor = hasError ? 'text-red-600' : 'text-green-600';
    const statusBg    = hasError ? 'bg-red-50 border-red-200' : 'bg-green-50 border-green-200';
    const statusIcon  = hasError ? '&#10060;' : '&#9989;';
    const $wrap = $('<div>');
    const $badge = $(`<div class="flex items-center gap-2 mb-1.5 text-[10px] font-mono border rounded px-2 py-1">`).addClass(statusColor).addClass(statusBg);
    $badge.append(
        $('<span>').html(`${statusIcon} exit: ${escape(String(r.exit_code))}`),
        $('<span class="text-gray-400">').text('|'),
        $('<span>').text(`time: ${r.execution_time}s`)
    );
    $wrap.append($badge);
    if (hasStdout) $wrap.append($('<div class="text-[10px] font-semibold text-gray-500 mb-0.5">').text('stdout'), $('<pre class="text-xs border rounded p-2 overflow-x-auto font-mono max-h-[200px] whitespace-pre-wrap">').css({'background-color':'#0a0b0c','color':'#c8d0d8','border-color':'#1a1b1c'}).text(stdout));
    if (hasStderr) $wrap.append($('<div class="text-[10px] font-semibold text-red-500 mb-0.5 mt-1">').text('stderr'), $('<pre class="text-xs bg-red-50 border border-red-200 rounded p-2 overflow-x-auto font-mono text-red-700 max-h-[200px] whitespace-pre-wrap">').text(stderr));
    if (!hasStdout && !hasStderr) {
        if (r.data && String(r.data).trim().length > 0) {
            $wrap.append($('<pre class="text-xs border rounded p-2 overflow-x-auto font-mono max-h-[200px] whitespace-pre-wrap">').css({'background-color':'#0a0b0c','color':'#c8d0d8','border-color':'#1a1b1c'}).text(String(r.data)));
        } else {
            $wrap.append($('<div class="text-xs text-gray-400 italic">').text('No output'));
        }
    }
    return $wrap;
}

function buildToolResultDetail(ev) {
    if (ev.error) {
        let msg = typeof ev.error === 'string' && ev.error.length > 1 ? ev.error : null;
        if (!msg && typeof ev.result === 'string') msg = ev.result;
        if (!msg && ev.result && typeof ev.result === 'object') msg = ev.result.error || ev.result.message || null;
        if (!msg) msg = 'Tool error';
        return $('<div class="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1 font-mono whitespace-pre-wrap">').text(msg);
    }

    // runpy
    if (ev.tool === 'runpy' && typeof ev.result === 'object' && ev.result && ev.result.exit_code !== undefined) {
        return _renderRunpyResult(ev.result);
    }
    // bash
    if ((ev.tool === 'bash' || ev.result?.exit_code !== undefined) && typeof ev.result === 'object' && ev.result && ev.result.exit_code !== undefined) {
        return _renderBashResult(ev.result);
    }
    // plain string
    if (typeof ev.result === 'string') {
        return $('<pre class="text-xs bg-gray-50 dark:bg-gray-900 dark:text-gray-300 border border-gray-200 rounded p-2 overflow-x-auto font-mono text-gray-700 max-h-[300px] whitespace-pre-wrap">').text(ev.result);
    }
    // single-key data wrapper
    if (typeof ev.result === 'object' && ev.result !== null && Object.keys(ev.result).length === 1 && 'data' in ev.result && typeof ev.result.data === 'string') {
        return $('<pre class="text-xs bg-gray-50 dark:bg-gray-900 dark:text-gray-300 border border-gray-200 rounded p-2 overflow-x-auto font-mono text-gray-700 max-h-[300px] whitespace-pre-wrap">').text(ev.result.data);
    }
    return $('<div class="text-xs text-green-700 bg-green-50 border border-green-200 rounded px-2 py-1">').text(summarizeToolResult(ev.result));
}

// ── Tool call args rendering ───────────────────────────────────────────────────

function _computeLineDiff(oldLines, newLines) {
    const m = oldLines.length, n = newLines.length;
    if (m * n > 60000) return [...oldLines.map(l => ({type:'remove',line:l})), ...newLines.map(l => ({type:'add',line:l}))];
    const dp = Array.from({length: m+1}, () => new Int32Array(n+1));
    for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) {
        dp[i][j] = oldLines[i-1] === newLines[j-1] ? dp[i-1][j-1]+1 : Math.max(dp[i-1][j], dp[i][j-1]);
    }
    const ops = []; let i = m, j = n;
    while (i > 0 || j > 0) {
        if (i > 0 && j > 0 && oldLines[i-1] === newLines[j-1]) { ops.push({type:'context', line:oldLines[i-1]}); i--; j--; }
        else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) { ops.push({type:'add', line:newLines[j-1]}); j--; }
        else { ops.push({type:'remove', line:oldLines[i-1]}); i--; }
    }
    return ops.reverse();
}

function _renderStrReplaceDiff(oldStr, newStr, filePath) {
    const oldLines = String(oldStr).split('\n'), newLines = String(newStr).split('\n');
    const ops = _computeLineDiff(oldLines, newLines);
    const changed = ops.map((op,i) => op.type !== 'context' ? i : -1).filter(i => i !== -1);
    if (!changed.length) return $('<div class="text-xs text-gray-400 italic mt-1">').text('No changes detected');
    const CTX = 3;
    const hunks = [];
    let hs = -1, he = -1;
    for (const idx of changed) {
        const lo = Math.max(0, idx-CTX), hi = Math.min(ops.length-1, idx+CTX);
        if (hs === -1 || lo > he+1) { if (hs !== -1) hunks.push([hs,he]); hs=lo; he=hi; }
        else he = Math.max(he, hi);
    }
    if (hs !== -1) hunks.push([hs,he]);

    let html = '';
    if (filePath) html += `<span class="hl-diff-filename">--- ${escape(String(filePath))}</span>\n<span class="hl-diff-filename">+++ ${escape(String(filePath))}</span>\n`;
    for (const [lo, hi] of hunks) {
        let oldLn=1,newLn=1;
        for (let k=0;k<lo;k++) { if(ops[k].type!=='add') oldLn++; if(ops[k].type!=='remove') newLn++; }
        let oldC=0,newC=0;
        for (let k=lo;k<=hi;k++) { if(ops[k].type!=='add') oldC++; if(ops[k].type!=='remove') newC++; }
        html += `<span class="hl-diff-header">@@ -${oldLn},${oldC} +${newLn},${newC} @@</span>\n`;
        for (let k=lo;k<=hi;k++) {
            const {type,line} = ops[k];
            const esc = escape(line);
            if (type==='add') html += `<span class="hl-diff-add">+${esc}</span>\n`;
            else if (type==='remove') html += `<span class="hl-diff-remove">-${esc}</span>\n`;
            else html += `<span class="hl-diff-context"> ${esc}</span>\n`;
        }
    }
    return $('<pre class="diff-code-block mt-0.5" style="max-height:400px;overflow-y:auto">').html(html);
}

function _buildToolCallDetail(tool, args, paramTypes) {
    const pt = paramTypes || {};
    const $wrap = $('<div>');

    const oldKey = 'old_string' in args ? 'old_string' : 'old_str' in args ? 'old_str' : null;
    const newKey = 'new_string' in args ? 'new_string' : 'new_str' in args ? 'new_str' : null;
    if (oldKey && newKey) {
        const filePath = args.file_path || args.path || null;
        const meta = {};
        for (const [k,v] of Object.entries(args)) { if (k!==oldKey && k!==newKey) meta[k]=v; }
        if (Object.keys(meta).length) $wrap.append(_renderParamTable(meta));
        $wrap.append($('<div class="text-[10px] uppercase tracking-wide text-green-500 font-semibold mt-1.5">').text('changes'));
        $wrap.append(_renderStrReplaceDiff(args[oldKey], args[newKey], filePath));
        return $wrap;
    }

    const inlineParams = {}, blockParams = [];
    for (const [key, value] of Object.entries(args)) {
        const view = pt[key] || null;
        if (view) blockParams.push({key, value, view});
        else inlineParams[key] = value;
    }
    if (Object.keys(inlineParams).length) $wrap.append(_renderParamTable(inlineParams));
    for (const {key, value, view} of blockParams) {
        $wrap.append($('<div class="text-[10px] uppercase tracking-wide text-blue-400 font-semibold mt-1.5">').text(key));
        if (view === 'code') $wrap.append($('<pre class="runpy-code-block mt-0.5">').html(highlightPython(String(value))));
        else if (view === 'diff') $wrap.append($('<pre class="diff-code-block mt-0.5">').html(highlightDiff(String(value))));
        else $wrap.append($('<pre class="text-xs bg-gray-50 dark:bg-gray-900 border border-gray-200 rounded p-2 overflow-x-auto font-mono text-gray-700 max-h-[300px] whitespace-pre-wrap">').text(String(value)));
    }
    return $wrap;
}

function _renderParamTable(params) {
    const $grid = $('<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs mt-1">');
    for (const [k, v] of Object.entries(params)) {
        const display = v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
        $grid.append(
            $('<span class="text-blue-400 font-semibold truncate">').text(k),
            $('<span class="text-blue-600 break-all">').text(display)
        );
    }
    return $grid;
}

// ── Thinking content ──────────────────────────────────────────────────────────

const THINKING_MAX_LINES = 25;

function _buildThinkingContent(content) {
    const lines = content.split('\n');
    if (lines.length <= THINKING_MAX_LINES) {
        return $('<pre class="p-2 dark:text-purple-300 whitespace-pre-wrap break-words overflow-x-auto max-w-full text-purple-800 text-[10px] leading-relaxed">').text(content);
    }
    const uid = 'tc-' + Math.random().toString(36).substr(2,8);
    const $pre = $('<pre class="whitespace-pre-wrap p-2 dark:text-purple-300 break-words overflow-x-auto max-w-full text-purple-800 text-[10px] leading-relaxed">').attr('id', uid).css({'max-height': THINKING_MAX_LINES*16+'px', overflow:'hidden', position:'relative'});
    $pre.text(lines.slice(0, THINKING_MAX_LINES).join('\n'));
    const remaining = lines.length - THINKING_MAX_LINES;
    const $fade = $('<span class="thinking-trim-fade absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-purple-50 to-transparent flex items-end justify-center pb-1">');
    const $btn = $('<button type="button" class="text-[10px] text-purple-500 hover:text-purple-700 font-medium cursor-pointer">').text(remaining + ' more lines…');
    $btn.on('click', function() {
        const el = document.getElementById(uid);
        if (el) { el.style.maxHeight = 'none'; el.style.overflow = ''; }
        $fade.remove();
    });
    $fade.append($btn);
    $pre.append($fade);
    return $pre;
}

// ── Timeline entry builder ────────────────────────────────────────────────────

function buildTimelineEntry(ev, isActive) {
    let borderClass, icon, label, labelClass, $summary, $detail, spinnerColor, extraAttrs = {};

    if (ev.type === 'thinking') {
        borderClass = 'border-purple-300'; icon = '&#129504;'; label = 'Thinking'; labelClass = 'text-purple-500'; spinnerColor = '#a855f7';
        $summary = $('<span class="text-[11px] text-gray-400 truncate max-w-[780px]">').text(truncateLine(ev.content, 80));
        $detail = _buildThinkingContent(ev.content);

    } else if (ev.type === 'tool_call') {
        borderClass = 'border-blue-300'; icon = '&#128295;'; label = 'Tool Call'; labelClass = 'text-blue-500'; spinnerColor = '#3b82f6';
        extraAttrs['data-tool-type'] = 'tool_call';
        extraAttrs['data-tool-name'] = ev.tool;
        $summary = $('<span class="text-[11px] text-gray-400 truncate max-w-[780px]">').text(ev.tool + '(' + truncateLine(JSON.stringify(ev.args), 60) + ')');
        $detail = _buildToolCallDetail(ev.tool, ev.args || {}, ev.param_types || {});

    } else if (ev.type === 'response') {
        borderClass = 'border-gray-300'; icon = '&#128172;'; label = 'Response'; labelClass = 'text-gray-500'; spinnerColor = '#6b7280';
        $summary = $('<span class="text-[11px] text-gray-400 truncate max-w-[780px]">').text(truncateLine(ev.content, 80));
        $detail = $('<pre class="whitespace-pre-wrap dark:text-gray-200 break-words overflow-x-auto max-w-full text-[11px] text-gray-700">').text(ev.content);

    } else if (ev.type === 'retry') {
        borderClass = 'border-yellow-300'; icon = '&#128260;'; label = 'Mencoba Ulang'; labelClass = 'text-yellow-600'; spinnerColor = '#f59e0b';
        $summary = $('<span class="text-[11px] text-gray-400 truncate max-w-[780px]">').text(ev.message || `Mencoba ulang... (${ev.retry_count}/${ev.max_retries})`);
        $detail = null;

    } else {
        return null;
    }

    const activeBorder = isActive ? 'border-transparent' : borderClass;
    const $entry = $('<div class="timeline-entry border-l-2 pl-3 py-1 relative">').addClass(activeBorder).attr('data-border', borderClass);
    for (const [k,v] of Object.entries(extraAttrs)) $entry.attr(k, v);

    if (isActive) {
        const $borderSpinner = $('<span class="tl-border-spinner">').append(
            $('<span class="tool-spinner">').css({'border-color': 'rgba(0,0,0,0.08)', 'border-top-color': spinnerColor})
        );
        $entry.append($borderSpinner);
    }

    const $headerRow = $('<div class="flex items-center gap-1 cursor-pointer select-none">');
    const $chev = $('<span class="tl-chev tool-trace-chevron text-[9px] text-gray-300">').html('&#9656;');
    const $iconSpan = $('<span class="text-[10px] font-semibold">').addClass(labelClass).html(icon);
    $headerRow.append($chev, $iconSpan);

    if (ev.type === 'tool_call') {
        $headerRow.append($('<span class="tl-status inline-flex items-center">'));
    }
    $headerRow.append($summary);

    const $detailWrap = $('<div class="tl-detail ml-5 hidden mt-1 overflow-x-hidden max-w-full">');
    if ($detail) $detailWrap.append($detail);

    $headerRow.on('click', function() {
        $detailWrap.toggleClass('hidden');
        $chev.toggleClass('rotated');
    });

    $entry.append($headerRow, $detailWrap);
    return $entry;
}

// ── Message bubble builders ───────────────────────────────────────────────────

const SYSTEM_BALLOON_TOGGLE = 'toggleSysBalloon'; // global fn exposed by index.js

function _buildSysBalloon(tag, content, tagColorClass, fullColorClass, truncateLen) {
    const previewText = content.replace(/\n/g, ' ').trim();
    const truncated = previewText.length > truncateLen ? previewText.substring(0, truncateLen) + '\u2026' : previewText;
    const needsCollapse = previewText.length > truncateLen;
    const sysId = 'sys-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);

    const $balloon = $('<div class="sys-balloon">').attr('data-sys-id', sysId);
    const $header = $('<div class="sys-balloon-header cursor-pointer flex items-start gap-1.5 whitespace-pre-wrap">');
    const $tagSpan = $('<span class="text-xs font-semibold mr-1.5">').addClass(tagColorClass).text(tag);
    const $preview = $('<span class="sys-balloon-content block">').append($tagSpan, document.createTextNode(truncated));
    $header.append($preview);
    if (needsCollapse) $header.append($('<span class="sys-chevron text-[10px] flex-shrink-0 mt-0.5">').addClass(fullColorClass).html('&#9660;'));

    const $full = $('<div class="sys-balloon-full whitespace-pre-wrap">').css('display','none').append(
        $('<span class="text-xs font-semibold mr-1.5">').addClass(tagColorClass).text(tag),
        document.createTextNode(content)
    );

    $header.on('click', () => {
        if (window.toggleSysBalloon) window.toggleSysBalloon(sysId);
    });

    $balloon.append($header, $full);
    return $balloon;
}

function buildMessageBubble(role, content, opts = {}, cfg = {}) {
    const {
        userAlign = 'right',
        assistantAlign = 'left',
        userBubbleClass = 'bg-indigo-500 text-white dark:bg-indigo-800',
        assistantBubbleClass = 'bg-gray-200 text-gray-800 dark:bg-[#152A3A] border dark:border-[#1B394F] dark:text-gray-100',
        agentAvatarUrl = null,
        showTimestamps = false,
        formatTimestamp = null,
    } = cfg;

    const isUser      = role === 'user';
    const isError     = role === 'error';
    const isSystem    = !isUser && !isError && role !== 'assistant' && /^\[system/i.test(content);
    const isSystemUser = isUser && /^\[system(?:\/[^\]]*)?\]/i.test(content);
    const isAgentUser  = isUser && /^\[AGENT\/[^\]]+\]/i.test(content);

    // In flex-col (mobile) mode the cross axis is horizontal, so use items-end/start.
    // In md:flex-row (desktop) mode the main axis is horizontal, so use justify-end/start.
    const isRight = isUser ? (userAlign === 'right') : (assistantAlign === 'right');
    const alignClass = isRight
        ? 'items-end md:items-start md:justify-end'
        : 'items-start md:justify-start';

    const avatarHtml = (!isUser && !isError && agentAvatarUrl)
        ? `<img src="${escape(agentAvatarUrl)}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0 mt-1 bg-indigo-50 dark:bg-indigo-900/20" onerror="this.onerror=null;this.style.display='none'">`
        : '';

    const $wrapper = $('<div>').addClass('flex flex-col md:flex-row').addClass(alignClass).attr('data-msg-role', role);
    if (avatarHtml) $wrapper.addClass('items-start gap-2').append($(avatarHtml));

    let $bubble;

    if (isAgentUser) {
        const agentMatch = content.match(/^(\[AGENT\/[^\]]+\])\s*/i);
        const agentTag = agentMatch ? agentMatch[1] : '';
        const agentContent = agentTag ? content.slice(agentMatch[0].length) : content;
        $bubble = $('<div class="bg-blue-100 text-blue-900 border border-blue-300 rounded-2xl px-4 py-2.5 text-sm break-words">');
        if (agentTag) $bubble.append($('<span class="text-xs font-semibold text-blue-500 mr-1.5">').text(agentTag));
        $bubble.append(document.createTextNode(agentContent));

    } else if (isSystemUser) {
        const sysMatch = content.match(/^(\[(?:SYSTEM(?:\/[^\]]*)?|System\/[^\]]*)\])\s*/);
        const sysTag = sysMatch ? sysMatch[1] : '';
        const sysContent = sysTag ? content.slice(sysMatch[0].length) : content;
        $bubble = $('<div class="bg-orange-100 text-orange-900 border border-orange-300 rounded-2xl px-4 py-2.5 text-sm break-words">');
        $bubble.append(_buildSysBalloon(sysTag, sysContent, 'text-orange-500', 'text-orange-400', 120));

    } else if (isUser) {
        $bubble = $('<div class="rounded-2xl px-4 py-2.5 text-sm whitespace-pre-wrap break-words">').addClass(userBubbleClass).text(content);

    } else if (isError) {
        const $icon = $('<svg class="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd"/></svg>');
        $bubble = $('<div class="bg-red-50 text-red-700 border border-red-200 rounded-lg px-4 py-2 text-sm flex items-start gap-2">').append($icon, $('<span>').text(content));

    } else if (isSystem) {
        const sysMatch = content.match(/^\[system[^\]]*\]\s*/i);
        const sysTag = sysMatch ? sysMatch[0].trim() : '[SYSTEM]';
        const sysContent = content.replace(/^\[system[^\]]*\]\s*/i, '');
        $bubble = $('<div class="bg-orange-200 text-gray-600 border-gray-400 rounded-2xl px-4 py-2.5 text-sm break-words">');
        $bubble.append(_buildSysBalloon(sysTag, sysContent, 'text-gray-500', 'text-gray-400', 120));

    } else {
        // assistant: markdown with sanitizer
        const rendered = typeof marked !== 'undefined'
            ? sanitize(marked.parse(content || '')).replace(/<table/g, '<div class="table-wrapper"><table').replace(/<\/table>/g, '</table></div>')
            : escape(content);
        $bubble = $('<div class="chat-prose rounded-2xl px-4 py-2.5 border-gray-300 text-sm break-words">').addClass(assistantBubbleClass);
        $bubble.attr('role', 'article');
        $bubble.html(rendered);
    }

    const $inner = $('<div class="max-w-[80%] min-w-0">').append($bubble);

    if (showTimestamps && opts.timestamp) {
        let tsStr = '';
        try {
            tsStr = formatTimestamp
                ? formatTimestamp(opts.timestamp)
                : new Date(opts.timestamp).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
        } catch(e) {}
        if (tsStr) {
            const tsAlign = isUser
                ? (userAlign === 'left' ? 'text-left' : 'text-right')
                : (assistantAlign === 'left' ? 'text-left' : 'text-right');
            $inner.append($('<div class="text-[10px] text-gray-300 mt-0.5 px-1">').addClass(tsAlign).text(tsStr));
        }
    }

    $wrapper.append($inner);
    return $wrapper;
}

// ── Default renderer registry ─────────────────────────────────────────────────

const DEFAULT_RENDERERS = {
    buildTimelineEntry,
    buildToolResultDetail,
    buildMessageBubble,
    sanitize,
    highlightPython,
    highlightDiff,
};

// ── transport.js ────────────────────────────────────────────────

// ── SSEAdapter ────────────────────────────────────────────────────────────────

const SSE_EVENTS = [
    'turn_begin', 'turn_split', 'thinking', 'tool_call_started', 'tool_executed',
    'response_chunk', 'done', 'approval_required', 'approval_resolved', 'retry',
    'message_injected', 'message_injection_applied', 'session_clear',
];

class SSEAdapter {
    
    constructor(url, opts = {}) {
        this._url = url;
        this._agentId = opts.agentId || '';
        this._sessionId = opts.sessionId || (new URL(url, window.location.origin).searchParams.get('session_id') || '');
        this._lastSeq = opts.afterSeq || 0;
        this._handler = null;
        this._es = null;
        this._fillingGap = false;
        this._pendingQueue = [];
        this._log = log('sse');
    }

    start(handler) {
        this._handler = handler;
        this._intentionallyStopped = false;
        this._connect(this._url);
    }

    stop() {
        this._intentionallyStopped = true;
        if (this._es) {
            this._es.close();
            this._es = null;
        }
    }

    _connect(url) {
        this._log.info('open', url);
        const es = new EventSource(url);
        this._es = es;

        for (const evtName of SSE_EVENTS) {
            es.addEventListener(evtName, (e) => {
                let data;
                try { data = JSON.parse(e.data); } catch (err) { data = {}; }
                this._log.debug('event', evtName, 'seq', data.seq, 'size', e.data.length);
                this._handleRaw(evtName, data);
            });
        }

        es.onerror = () => {
            this._log.warn('SSE error/closed', url);
            console.warn('[sse] error/closed _lastSeq=', this._lastSeq, '_fillingGap=', this._fillingGap, '_pendingQueue=', this._pendingQueue.length);
            es.close();
            if (this._es === es) this._es = null;
            // Only reconnect if this was NOT an intentional stop (e.g. after 'done')
            if (this._intentionallyStopped) {
                this._log.info('intentionally stopped — no reconnect');
                return;
            }
            setTimeout(() => {
                if (this._intentionallyStopped) return;
                if (this._es) return; // another stream already started
                const u = new URL(url, window.location.origin);
                if (this._lastSeq > 0) u.searchParams.set('after', this._lastSeq);
                const resumeUrl = u.pathname + u.search;
                this._log.info('reconnecting from seq', this._lastSeq, resumeUrl);
                console.warn('[sse] reconnecting _lastSeq=', this._lastSeq, '_fillingGap=', this._fillingGap, '_pendingQueue=', this._pendingQueue.length, 'url=', resumeUrl);
                this._connect(resumeUrl);
            }, 2000);
        };
    }

    _handleRaw(evtName, data) {
        const seq = data.seq || 0;

        if (this._fillingGap) {
            this._log.debug('queued while filling gap', evtName, 'seq', seq, 'queueLen', this._pendingQueue.length);
            if (this._pendingQueue.length >= 1 && this._pendingQueue.length % 10 === 0) {
                console.warn('[sse] pendingQueue grew to', this._pendingQueue.length, 'while filling gap — possible reconnect storm');
            }
            this._pendingQueue.push({ evtName, data });
            return;
        }

        if (seq && seq <= this._lastSeq) {
            this._log.debug('dedup skip', evtName, 'seq', seq, '≤ lastSeq', this._lastSeq);
            console.log('[sse] dedup skip', evtName, 'seq=', seq, '_lastSeq=', this._lastSeq);
            return;
        }

        if (seq && this._lastSeq > 0 && seq > this._lastSeq + 1) {
            this._log.warn('seq gap detected', this._lastSeq, '→', seq, '— filling');
            this._fillingGap = true;
            this._pendingQueue.push({ evtName, data });
            this._fillGap(this._lastSeq, seq).then(() => {
                this._fillingGap = false;
                console.warn('[sse] draining pendingQueue len=', this._pendingQueue.length, '_lastSeq=', this._lastSeq);
                this._drainQueue();
            });
            return;
        }

        if (seq) this._lastSeq = seq;
        this._dispatch(evtName, data);
    }

    async _fillGap(afterSeq, upToSeq) {
        try {
            const agentId = this._agentId || this._url.match(/\/agents\/([^/?]+)\//)?.[1] || '';
            const res = await $.getJSON(
                `/api/agents/${encodeURIComponent(agentId)}/chat/events?session_id=${encodeURIComponent(this._sessionId)}&after=${afterSeq}&up_to=${upToSeq}`
            );
            const evts = res.events || [];
            this._log.warn('gap-fill response: afterSeq=' + afterSeq + ' upToSeq=' + upToSeq + ' returned=' + evts.length + ' seqs=' + evts.map(e=>e.seq).join(','));
            console.warn('[gap-fill] returned', evts.length, 'events for after=', afterSeq, 'up_to=', upToSeq, 'seqs:', evts.map(e=>e.seq).join(','));
            for (const ev of evts) {
                if (ev.seq <= this._lastSeq) continue;
                this._lastSeq = ev.seq;
                this._dispatch(ev.event, ev.data);
            }
        } catch (err) {
            this._log.warn('gap-fill failed', err, '— skipping gap');
            this._lastSeq = upToSeq - 1;
        }
    }

    // Drain _pendingQueue asynchronously — one event per animation frame so we
    // never block the main thread with a large synchronous burst.
    _drainQueue() {
        if (this._pendingQueue.length === 0) {
            console.warn('[sse] queue drain done _lastSeq=', this._lastSeq);
            return;
        }
        const item = this._pendingQueue.shift();
        const itemSeq = item.data.seq || 0;
        if (itemSeq && itemSeq <= this._lastSeq) {
            console.log('[sse] queue dedup skip', item.evtName, 'seq=', itemSeq);
            // Skip but continue draining without waiting — dedup is cheap
            this._drainQueue();
            return;
        }
        if (itemSeq) this._lastSeq = itemSeq;
        this._dispatch(item.evtName, item.data);
        // Yield to the browser between each real event
        requestAnimationFrame(() => this._drainQueue());
    }

    _dispatch(evtName, data) {
        if (evtName === 'session_clear') {
            this._handler({ event: 'session_clear', data, seq: data.seq || 0 });
            return;
        }
        // done: stop reconnecting after this
        if (evtName === 'done') {
            this._handler({ event: 'done', data, seq: data.seq || 0 });
            this.stop();
            return;
        }
        this._handler({ event: evtName, data, seq: data.seq || 0 });
    }
}

// ── PollingAdapter ────────────────────────────────────────────────────────────

class PollingAdapter {
    
    constructor(baseUrl, opts = {}) {
        this._baseUrl = baseUrl;
        this._sessionId = opts.sessionId || '';
        this._intervalMs = opts.intervalMs || 1000;
        this._lastTs = opts.startTs || 0;
        this._handler = null;
        this._timer = null;
        this._running = false;
        this._log = log('poll');
    }

    start(handler) {
        this._handler = handler;
        this._running = true;
        this._timer = setInterval(() => this._tick(), this._intervalMs);
        this._log.info('polling started', this._baseUrl);
    }

    stop() {
        this._running = false;
        if (this._timer) { clearInterval(this._timer); this._timer = null; }
        this._log.info('polling stopped');
    }

    async _tick() {
        if (!this._running) return;
        try {
            const qs = this._sessionId
                ? `session_id=${encodeURIComponent(this._sessionId)}&after_ts=${this._lastTs}&limit=50`
                : `user_id=web_test&after_ts=${this._lastTs}&limit=50`;
            const data = await $.getJSON(`${this._baseUrl}?${qs}`);
            const entries = data.entries || [];
            this._log.debug('tick delta', entries.length, 'lastTs', this._lastTs);
            if (!entries.length) return;

            for (const entry of entries) {
                if (entry.ts <= this._lastTs) continue;
                this._lastTs = entry.ts;

                // Convert JSONL entry to TurnEvent
                const evt = this._entryToEvent(entry);
                if (evt) {
                    // Progressive: one event per rAF to avoid dumping all at once
                    await new Promise(r => requestAnimationFrame(r));
                    this._handler(evt);
                }
            }
        } catch (e) {
            this._log.warn('poll tick error', e);
        }
    }

    _entryToEvent(entry) {
        if (entry.type === 'final') {
            return { event: 'done', data: { thinking_duration: (entry.metadata || {}).thinking_duration }, seq: 0, _entry: entry };
        }
        if (entry.type === 'error') {
            return { event: 'done', data: { thinking_duration: null, error: true }, seq: 0, _entry: entry };
        }
        if (entry.type === 'thinking') {
            return { event: 'thinking', data: { content: entry.content }, seq: 0 };
        }
        if (entry.type === 'tool_call') {
            return { event: 'tool_call_started', data: { tool: entry.function, args: entry.params || {}, param_types: {} }, seq: 0 };
        }
        if (entry.type === 'tool_output') {
            let result;
            try { result = JSON.parse(entry.content); } catch (e) { result = { data: entry.content }; }
            return { event: 'tool_executed', data: { tool: entry.function, result, error: !!entry.error }, seq: 0 };
        }
        if (entry.type === 'intermediate') {
            return { event: 'response_chunk', data: { content: entry.content, is_final: false }, seq: 0 };
        }
        return null;
    }
}

// ── ReplayAdapter ─────────────────────────────────────────────────────────────

class ReplayAdapter {
    
    constructor(events, opts = {}) {
        this._events = events;
        this._batch = opts.batch !== false;
        this._handler = null;
        this._stopped = false;
        this._log = log('replay');
    }

    start(handler) {
        this._handler = handler;
        if (this._batch) {
            for (const evt of this._events) {
                if (this._stopped) break;
                handler(evt);
            }
        } else {
            this._playAsync();
        }
    }

    stop() {
        this._stopped = true;
    }

    async _playAsync() {
        for (const evt of this._events) {
            if (this._stopped) break;
            await new Promise(r => requestAnimationFrame(r));
            this._handler(evt);
        }
    }
}

// ── turn.js ─────────────────────────────────────────────────────

const STALE_TIMEOUT_MS = 300_000; // 5 minutes — safety net for truly abandoned turns

const TERMINAL_PHASES = new Set(['final', 'done', 'aborted']);

function reduceTurn(phase, eventKind) {
    if (phase === 'pending') {
        if (eventKind === 'turn_begin')        return 'thinking';
        if (eventKind === 'thinking')          return 'thinking';
        if (eventKind === 'tool_call_started') return 'tool_running';
        if (eventKind === 'response_chunk' || eventKind === 'done') return 'final';
        if (eventKind === 'approval_required') return 'awaiting_approval';
    }
    if (phase === 'thinking') {
        if (eventKind === 'tool_call_started') return 'tool_running';
        if (eventKind === 'response_chunk' || eventKind === 'done') return 'final';
        if (eventKind === 'approval_required') return 'awaiting_approval';
        if (eventKind === 'thinking')          return 'thinking'; // no-op transition
        if (eventKind === 'retry')             return 'thinking';
        if (eventKind === 'turn_split')        return 'pending';
    }
    if (phase === 'tool_running') {
        if (eventKind === 'tool_executed')     return 'tool_done';
        if (eventKind === 'response_chunk' || eventKind === 'done') return 'final';
        if (eventKind === 'approval_required') return 'awaiting_approval';
    }
    if (phase === 'tool_done') {
        if (eventKind === 'thinking')          return 'thinking';
        if (eventKind === 'tool_call_started') return 'tool_running';
        if (eventKind === 'response_chunk' || eventKind === 'done') return 'final';
        if (eventKind === 'approval_required') return 'awaiting_approval';
    }
    if (phase === 'awaiting_approval') {
        if (eventKind === 'approval_resolved') return 'thinking';
    }
    if (phase === 'final') {
        if (eventKind === 'done')              return 'done';
        if (eventKind === 'turn_split')        return 'pending';
    }
    // abort from any phase
    if (eventKind === 'abort')                 return 'aborted';
    return null; // illegal / no-op
}

function deriveSpinnerLocation(phase) {
    if (phase === 'pending' || phase === 'thinking' || phase === 'awaiting_approval') return 'header';
    if (phase === 'tool_running') return 'entry';
    if (phase === 'tool_done')    return 'thinking_row';
    return 'none';
}

let _turnCounter = 0;

class Turn {
    
    constructor(opts) {
        this.id = 'turn-' + Date.now() + '-' + (++_turnCounter);
        this.$anchor = opts.$anchor;
        this._$container = opts.$container;
        this._renderers = opts.renderers;
        this._agentAvatarUrl = opts.agentAvatarUrl || null;
        this._assistantAlign = opts.assistantAlign || 'left';
        this._onPhaseChange = opts.onPhaseChange || (() => {});
        this._onDispose = opts.onDispose || (() => {});
        this._onTrigger = opts.onTrigger || (() => {});
        this._agentId = opts.agentId || '';

        this.phase = 'pending';
        this._lastSeq = 0;
        this._transports = [];
        this._timerInterval = null;
        this._staleTimeout = null;
        this._scrollRAF = null;
        this._startTime = Date.now();
        this._finalized = false;
        this._preApprovalPhase = 'thinking';

        this._log = log('turn');

        this._buildDOM();
        this._startTimer();
        this._armStaleTimeout();
    }

    // ── DOM ──────────────────────────────────────────────────────────────────

    _buildDOM() {
        const alignClass = this._assistantAlign === 'right' ? 'justify-end' : 'justify-start';
        const avatarHtml = this._agentAvatarUrl
            ? `<img src="${this._agentAvatarUrl}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0 mt-1 bg-indigo-50 dark:bg-indigo-900/20" onerror="this.onerror=null;this.style.display='none'">`
            : '';

        // Thinking bubble header
        this.$bubble = $(`<div class="flex ${alignClass} ${avatarHtml ? 'items-start gap-2' : ''}"
             role="status" aria-live="polite" aria-label="Agent is thinking"
             data-turn-id="${this.id}">`);

        const $inner = $('<div class="thinking-bubble flex items-center gap-2 rounded-lg px-3 py-2 text-xs border border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-900/60 cursor-pointer">');
        $inner.append(
            $('<span>').html('&#129504;'),
            $('<span class="font-medium text-purple-700 dark:text-purple-200">').text('Thinking'),
            $('<span class="thinking-step-count text-purple-500 dark:text-purple-300 text-[10px] ml-0.5 hidden">'),
            $('<span class="thinking-spinner">').append($('<span class="tool-spinner">')),
            $('<span class="thinking-timer-inline text-purple-500 dark:text-purple-400 text-[10px]">').text('0.0 s'),
            $('<span class="ml-1 text-purple-500 dark:text-purple-300 tool-trace-chevron text-sm">').html('&#9656;')
        );

        if (avatarHtml) this.$bubble.append($(avatarHtml));
        this.$bubble.append($inner);

        // Timeline panel container
        this.$panel = $(`<div class="flex ${alignClass}" data-turn-id="${this.id}">`);
        const $panelInner = $('<div class="ml-5 max-w-[80%]">');
        this.$timeline = $('<div class="timeline-panel hidden space-y-0.5 py-0.5">');
        $panelInner.append(this.$timeline);
        this.$panel.append($panelInner);

        // Click to expand/collapse timeline
        $inner.on('click', () => {
            this.$timeline.toggleClass('hidden');
            $inner.find('.tool-trace-chevron').toggleClass('rotated');
        });

        // Always append at the end of the container. Inserting after $anchor would
        // place the bubble in the middle of the conversation when there are already
        // agent messages rendered after the user message (e.g. turn_split, replay).
        this._$container.append(this.$bubble);
        this.$bubble.after(this.$panel);
    }

    _startTimer() {
        const timerEl = this.$bubble.find('.thinking-timer-inline')[0];
        if (!timerEl) return;
        this._timerInterval = setInterval(() => {
            const elapsed = Date.now() - this._startTime;
            timerEl.textContent = (elapsed / 1000).toFixed(1) + ' s';
        }, 100);
    }

    _armStaleTimeout() {
        this._staleTimeout = setTimeout(() => {
            this._log.warn('stale timeout reached, auto-finalizing', this.id);
            this._finalizeBubble(null);
        }, STALE_TIMEOUT_MS);
    }

    _clearTimers() {
        if (this._timerInterval) { clearInterval(this._timerInterval); this._timerInterval = null; }
        if (this._staleTimeout)  { clearTimeout(this._staleTimeout);   this._staleTimeout = null; }
        if (this._scrollRAF)     { cancelAnimationFrame(this._scrollRAF); this._scrollRAF = null; }
    }

    // ── State machine ────────────────────────────────────────────────────────

    
    ingest(evt) {
        const { event: evtName, data = {}, seq = 0 } = evt;

        // Drop events after finalization — prevents a reconnected SSEAdapter from
        // feeding a completed turn with the next turn's events.
        if (this._finalized) {
            this._log.warn('ingest after finalize ignored', evtName, this.id);
            console.warn('[turn] ingest ignored (finalized) event=%s turn=%s', evtName, this.id);
            return;
        }

        // Sequence deduplication
        if (seq && seq <= this._lastSeq) {
            this._log.debug('dedup seq', seq, '≤', this._lastSeq, this.id);
            return;
        }
        if (seq) this._lastSeq = seq;

        // Reset stale timeout on every live event — turn is clearly still active
        if (this._staleTimeout) {
            clearTimeout(this._staleTimeout);
            this._staleTimeout = setTimeout(() => {
                this._log.warn('stale timeout reached, auto-finalizing', this.id);
                this._finalizeBubble(null);
            }, STALE_TIMEOUT_MS);
        }

        this._log.debug('ingest', evtName, seq, '→ phase was', this.phase, this.id);

        const nextPhase = reduceTurn(this.phase, evtName);
        if (nextPhase !== null && nextPhase !== this.phase) {
            const prev = this.phase;
            this.phase = nextPhase;
            this._log.info('phase', prev, '→', nextPhase, 'via', evtName, this.id);
            this._onPhaseChange(this, prev, nextPhase);
            this._updateSpinner(nextPhase);
        }

        this._handleEventRendering(evtName, data);
    }

    _updateSpinner(phase) {
        const loc = deriveSpinnerLocation(phase);
        this._log.debug('spinner →', loc, this.id);

        const $spinner = this.$bubble.find('.thinking-spinner');
        const $approvalColor = this.$bubble.find('.thinking-bubble');

        if (phase === 'awaiting_approval') {
            $approvalColor
                .removeClass('border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-900/60')
                .addClass('border-orange-300 bg-orange-50 dark:border-orange-700 dark:bg-orange-900/60');
            this.$bubble.find('span:first').html('&#128274;');
            this.$bubble.find('.font-medium')
                .text('Menunggu Approval')
                .removeClass('text-purple-700 dark:text-purple-200')
                .addClass('text-orange-700 dark:text-orange-200');
        } else if (this._lastApprovalPhase !== phase && (phase === 'thinking' || phase === 'tool_running' || phase === 'tool_done')) {
            $approvalColor
                .addClass('border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-900/60')
                .removeClass('border-orange-300 bg-orange-50 dark:border-orange-700 dark:bg-orange-900/60');
            this.$bubble.find('span:first').html('&#129504;');
            this.$bubble.find('.font-medium')
                .text('Thinking')
                .addClass('text-purple-700 dark:text-purple-200')
                .removeClass('text-orange-700 dark:text-orange-200');
        }

        if (loc === 'none' || TERMINAL_PHASES.has(phase)) {
            $spinner.hide();
        } else {
            $spinner.show();
        }
    }

    _handleEventRendering(evtName, data) {
        if (evtName === 'turn_begin') {
            // Already in thinking phase — bubble is ready
            if (data.ts) this._startTime = data.ts;
            return;
        }

        if (evtName === 'thinking') {
            this._addTimelineEntry({ type: 'thinking', content: data.content || '' });
            return;
        }

        if (evtName === 'tool_call_started') {
            this._addTimelineEntry({ type: 'tool_call', tool: data.tool, args: data.args || {}, param_types: data.param_types || {} });
            return;
        }

        if (evtName === 'tool_executed') {
            this._mergeToolResult(data);
            this._showThinkingRow();
            // Fire tool:executed for agent-state-bridge
            this._onTrigger('tool:executed', data);
            return;
        }

        if (evtName === 'response_chunk' && data.is_final && data.content) {
            this._addTimelineEntry({ type: 'response', content: data.content });
            return;
        }

        if (evtName === 'retry') {
            this._addTimelineEntry({ type: 'retry', message: data.message, retry_count: data.retry_count, max_retries: data.max_retries });
            return;
        }

        if (evtName === 'approval_required') {
            this._addApprovalCard(data);
            this._onTrigger('approval:required', data);
            return;
        }

        if (evtName === 'approval_resolved') {
            this._resolveApprovalCard(data);
            this._onTrigger('approval:resolved', data);
            return;
        }

        if (evtName === 'done') {
            this._finalizeBubble(data.thinking_duration);
            return;
        }

        if (evtName === 'turn_split') {
            this._finalizeBubble(null);
            // ChatUI will create a new Turn for the continuation
            this._onTrigger('turn:split', { turnId: this.id });
            return;
        }

        if (evtName === 'session_clear') {
            // Propagate to ChatUI.onTrigger which calls ChatUI.clear()
            this._onTrigger('session_clear', data);
            return;
        }
    }

    // ── Timeline entries ──────────────────────────────────────────────────────

    _addTimelineEntry(ev) {
        const total = this.$timeline.find('.timeline-entry').length;
        if (total > 0 && total % 10 === 0) {
            console.warn('[turn] _addTimelineEntry count=%d type=%s turn=%s — possible duplicate replay?', total + 1, ev.type, this.id);
        }
        // Remove "Thinking..." placeholder when a new event arrives
        this.$timeline.find('.tl-thinking-pending').remove();

        // Deactivate previous last entry
        const $prevLast = this.$timeline.find('.timeline-entry:last-child');
        if ($prevLast.length) this._deactivateEntry($prevLast);

        const $entry = this._safeRender('timeline:' + ev.type, () => this._renderers.buildTimelineEntry(ev, true));
        if (!$entry) return;

        this.$timeline.append($entry);
        this.$timeline.removeClass('hidden');

        // Update step count badge
        const toolCount = this.$timeline.find('.timeline-entry[data-tool-type="tool_call"]').length;
        const $stepEl = this.$bubble.find('.thinking-step-count');
        if (toolCount > 0) {
            $stepEl.text(toolCount + ' tools').removeClass('hidden');
        }

        this._smartScroll();
    }

    _mergeToolResult(data) {
        const $panel = this.$timeline;
        const $calls = $panel.find('.timeline-entry[data-tool-type="tool_call"]');
        let $target = null;
        $calls.each(function() {
            if ($(this).attr('data-tool-name') === data.tool && !$(this).find('.tl-detail > .mt-2').length) {
                $target = $(this);
                return false; // break
            }
        });
        if (!$target) $target = $calls.last();
        if (!$target || !$target.length) return;

        this._deactivateEntry($target);

        const newBorder = data.error ? 'border-red-300' : 'border-green-300';
        $target.removeClass('border-blue-300 border-transparent').addClass(newBorder).attr('data-border', newBorder);

        const $status = $target.find('.tl-status');
        if ($status.length) {
            $status.html(data.error
                ? '<span class="text-[14px] font-bold leading-none" style="color:#ef4444">&#10005;</span>'
                : '<span class="text-[14px] font-bold leading-none" style="color:#22c55e">&#10003;</span>');
        }

        const $detail = $target.find('.tl-detail');
        if ($detail.length) {
            const resultLabelColor = data.error ? 'text-red-400' : 'text-green-400';
            const $wrapper = $('<div class="mt-2 pt-2 border-t border-gray-100">');
            const labelSpan = $('<span class="text-[10px] uppercase tracking-wide font-semibold block mb-1">').addClass(resultLabelColor).text('Result');
            const resultContent = this._safeRender('tool_result_detail', () => this._renderers.buildToolResultDetail(data));
            $wrapper.append(labelSpan);
            if (resultContent) $wrapper.append(resultContent);
            $detail.append($wrapper);
        }
    }

    _showThinkingRow() {
        if (this._finalized) return; // don't add a pending row to a completed turn
        this.$timeline.find('.tl-thinking-pending').remove();
        const $pending = $('<div class="tl-thinking-pending border-l-2 border-transparent pl-3 py-0.5 relative">');
        $pending.append(
            $('<span class="tl-border-spinner">').append(
                $('<span class="tool-spinner">').css({'border-color': 'rgba(168,85,247,0.15)', 'border-top-color': '#a855f7'})
            ),
            $('<span class="text-[11px] text-purple-500 dark:text-purple-400">').text('Thinking...')
        );
        this.$timeline.append($pending);
        this._smartScroll();
    }

    _deactivateEntry($entry) {
        const savedBorder = $entry.attr('data-border');
        if (savedBorder) $entry.removeClass('border-transparent').addClass(savedBorder);
        $entry.find('.tl-border-spinner').remove();
    }

    // ── Finalization ──────────────────────────────────────────────────────────

    _finalizeBubble(duration) {
        if (this._finalized) return;
        this._finalized = true;
        this._clearTimers();

        this.$bubble.attr('data-finalized', 'true');
        this.$bubble.find('.thinking-spinner').remove();

        const $timer = this.$bubble.find('.thinking-timer-inline');
        if ($timer.length) {
            if (duration != null) {
                $timer.text(Number(duration).toFixed(1) + ' s');
            } else {
                $timer.addClass('hidden');
            }
        }

        this.$timeline.find('.tl-thinking-pending').remove();
        this._deactivateEntry(this.$timeline.find('.timeline-entry:last-child'));

        this.phase = 'done';
        this._onPhaseChange(this, 'final', 'done');
        this._log.info('finalized', this.id, 'duration=', duration);
    }

    // ── Approval card ─────────────────────────────────────────────────────────

    _addApprovalCard(data) {
        const entryCount = this.$timeline.find('.timeline-entry').length;
        const pendingCount = this.$timeline.find('.tl-thinking-pending').length;
        this._log.warn('approval card shown — timeline entries:', entryCount, 'pending rows:', pendingCount, 'phase:', this.phase, 'finalized:', this._finalized, 'turn:', this.id);
        console.warn('[approval] card shown. timeline entries=%d pending=%d phase=%s finalized=%s', entryCount, pendingCount, this.phase, this._finalized);
        if (data.approval_id && this.$timeline.find(`[data-approval-id="${CSS.escape(data.approval_id)}"]`).length) return;

        const riskLevel = (data.approval_info && data.approval_info.risk_level) || 'medium';
        const riskColorClass = riskLevel === 'high' ? 'text-red-500 dark:text-red-400'
            : riskLevel === 'low' ? 'text-yellow-500 dark:text-yellow-400'
            : 'text-orange-500 dark:text-orange-400';
        const riskBgClass = riskLevel === 'high' ? 'bg-red-50/80 border-red-300 dark:bg-red-950/40 dark:border-red-800'
            : riskLevel === 'low' ? 'bg-yellow-50/80 border-yellow-300 dark:bg-yellow-950/40 dark:border-yellow-800'
            : 'bg-orange-50/80 border-orange-300 dark:bg-orange-950/40 dark:border-orange-800';
        const description = (data.approval_info && data.approval_info.description) || 'This action requires careful consideration.';
        const reasons = (data.reasons || []);
        const toolArgs = data.tool_args || {};
        const codeSnippet = toolArgs.script || toolArgs.code || null;
        const codeLang = toolArgs.script !== undefined ? 'bash' : 'python';

        const $card = $('<div class="approval-card timeline-entry rounded-lg mb-2">').addClass(riskBgClass).addClass('border').attr('data-approval-id', data.approval_id);

        const $summary = $('<div class="approval-summary hidden items-center gap-2 px-3 py-2 cursor-pointer select-none hover:opacity-80 transition-opacity" title="Click to expand">');
        const $summaryIcon = $('<span class="approval-summary-icon text-xs font-bold">');
        const $summaryTool = $('<span class="text-xs text-gray-600 dark:text-gray-300">').append(
            $('<span class="font-semibold">').text('Tool: '),
            $('<code class="bg-white/60 dark:bg-gray-700/60 px-1 rounded">').text(data.tool || '')
        );
        const $summaryChevron = $('<span class="ml-auto text-[10px] text-gray-400 flex items-center gap-1">').html('details <svg class="approval-chevron inline w-3 h-3 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>');
        $summary.append($summaryIcon, $summaryTool, $summaryChevron);

        const $details = $('<div class="approval-details p-3">');
        $details.append(
            $('<div class="flex items-center gap-2 mb-2">').append(
                $('<span class="text-sm font-semibold">').addClass(riskColorClass).html('&#9888; Approval Required'),
                $('<span class="text-[10px] uppercase tracking-wide font-semibold px-1.5 py-0.5 rounded border border-current">').addClass(riskColorClass).text(riskLevel)
            ),
            $('<div class="text-xs text-gray-700 dark:text-gray-200 mb-1">').append(
                $('<span class="font-semibold">').text('Tool: '),
                $('<code class="bg-white/60 dark:bg-gray-700/60 px-1 rounded">').text(data.tool || '')
            ),
            $('<div class="text-xs text-gray-600 dark:text-gray-300 mb-2">').text(description)
        );

        if (reasons.length) {
            const $ul = $('<ul class="text-xs text-gray-600 dark:text-gray-300 list-disc list-inside mb-2 space-y-0.5">');
            reasons.forEach(r => $ul.append($('<li>').text(r)));
            $details.append($ul);
        }

        if (codeSnippet) {
            $details.append(
                $('<div class="mb-2">').append(
                    $('<div class="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-1">').text('Code ').append($('<span class="font-normal text-gray-400">').text('(' + codeLang + ')')),
                    $('<pre class="text-xs bg-gray-900 text-gray-100 rounded p-2 overflow-auto max-h-48 whitespace-pre-wrap break-all">').append($('<code>').text(codeSnippet))
                )
            );
        }

        const $actions = $('<div class="approval-actions flex gap-2 mt-2">');
        const $approveBtn = $('<button class="approve-btn text-xs font-semibold px-3 py-1.5 rounded bg-green-600 text-white hover:bg-green-700 transition-colors">').text('Approve');
        const $rejectBtn = $('<button class="reject-btn text-xs font-semibold px-3 py-1.5 rounded bg-red-600 text-white hover:bg-red-700 transition-colors">').text('Reject');
        $actions.append($approveBtn, $rejectBtn);

        const $statusEl = $('<div class="approval-status text-xs font-semibold mt-1 hidden">');
        $details.append($actions, $statusEl);
        $card.append($summary, $details);

        $approveBtn.on('click', () => this._submitApproval(data.approval_id, 'approve', $card));
        $rejectBtn.on('click', () => this._submitApproval(data.approval_id, 'reject', $card));
        $summary.on('click', () => {
            const isHidden = $details.toggleClass('hidden').hasClass('hidden');
            $summary.find('.approval-chevron').css('transform', isHidden ? '' : 'rotate(180deg)');
        });

        this.$timeline.append($card);
        this.$timeline.removeClass('hidden');
        this._smartScroll();
    }

    async _submitApproval(approvalId, decision, $card) {
        $card.find('.approval-actions button').prop('disabled', true);
        try {
            const res = await fetch(`/api/agents/${encodeURIComponent(this._agentId)}/chat/approve`, {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({approval_id: approvalId, decision}),
            });
            if (res.ok) {
                this._markApprovalResolved($card, decision, false);
            } else {
                const body = await res.json().catch(() => ({}));
                $card.find('.approval-status').text(body.error || 'Failed.').removeClass('hidden').addClass('text-red-600');
                $card.find('.approval-actions button').prop('disabled', false);
            }
        } catch (e) {
            $card.find('.approval-status').text('Network error.').removeClass('hidden').addClass('text-red-600');
            $card.find('.approval-actions button').prop('disabled', false);
        }
    }

    _resolveApprovalCard(data) {
        const $card = this.$timeline.find(`[data-approval-id="${CSS.escape(data.approval_id)}"]`);
        if ($card.length) this._markApprovalResolved($card, data.decision, data.timed_out);
    }

    _markApprovalResolved($card, decision, timedOut) {
        $card.find('.approval-actions').addClass('hidden');
        let statusText, statusClass, iconHtml;
        if (timedOut) {
            statusText = 'Timed out — auto-rejected.'; statusClass = 'text-gray-500'; iconHtml = '&#x23F1; Timed out';
        } else if (decision === 'approve') {
            statusText = 'Approved — executing...'; statusClass = 'text-green-600'; iconHtml = '&#10003; Approved';
        } else {
            statusText = 'Rejected.'; statusClass = 'text-red-500'; iconHtml = '&#10007; Rejected';
        }
        $card.find('.approval-status').text(statusText).removeClass('hidden').addClass(statusClass);
        $card.find('.approval-summary-icon').html(iconHtml);
        $card.find('.approval-summary').removeClass('hidden').addClass('flex');
        $card.find('.approval-details').addClass('hidden');
    }

    // ── Transport ─────────────────────────────────────────────────────────────

    attach(transport) {
        this._transports.push(transport);
        transport.start((evt) => this.ingest(evt));
    }

    // ── Disposal ──────────────────────────────────────────────────────────────

    dispose(reason) {
        this._log.info('dispose', this.id, 'reason=', reason);
        this._clearTimers();
        this._transports.forEach(t => { try { t.stop(); } catch(e) {} });
        this._transports = [];

        // Only remove DOM if not finalized
        if (!this._finalized) {
            this.$bubble.remove();
            this.$panel.remove();
        }

        this._onDispose(this, reason);
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    _safeRender(name, fn) {
        try {
            return fn();
        } catch (err) {
            log('render').error(`renderer "${name}" threw`, err);
            this._onTrigger('renderer:error', { renderer: name, error: err });
            return $('<div class="text-xs text-red-400 italic px-2 py-1">').text(`[render error in ${name}: ${err.message}]`);
        }
    }

    _smartScroll() {
        // Defer the scroll check to the next animation frame to avoid forced synchronous
        // layout reflows during batch replay (many entries inserted in a tight loop).
        if (this._scrollRAF) return; // already scheduled for this frame
        this._scrollRAF = requestAnimationFrame(() => {
            this._scrollRAF = null;
            const c = this._$container[0];
            if (!c) return;
            if (c.scrollHeight - c.scrollTop - c.clientHeight < 300) {
                c.scrollTop = c.scrollHeight;
            }
        });
    }
}

// ── index.js ────────────────────────────────────────────────────

// ── Perspective helpers ───────────────────────────────────────────────────────

const PERSPECTIVES = {
    'A': { userAlign: 'right', assistantAlign: 'left' },
    'B': { userAlign: 'left',  assistantAlign: 'right' },
    // legacy aliases
    'user':  { userAlign: 'right', assistantAlign: 'left' },
    'agent': { userAlign: 'left',  assistantAlign: 'right' },
};

// ── ChatUI class ──────────────────────────────────────────────────────────────

class ChatUI {
    
    constructor(container, opts = {}) {
        this.$container = $(container);
        this._perspective = opts.perspective || 'user';
        this._opts = Object.assign({
            showTimestamps: false,
            agentAvatarUrl: null,
            agentId: '',
            formatTimestamp: null,
            userBubbleClass: 'bg-indigo-500 text-white dark:bg-indigo-800',
            assistantBubbleClass: 'bg-gray-200 text-gray-800 dark:bg-[#152A3A] border dark:border-[#1B394F] dark:text-gray-100',
        }, opts);
        this._renderers = Object.assign({}, DEFAULT_RENDERERS, opts.renderers || {});
        this._turns = new Map();   // id → Turn
        this._eventLog = [];       // last 200 TurnEvents
        this.$bus = $('<div>');    // jQuery event bus
        this._lastLiveTurnId = null; // ID of the most-recent live SSE turn, for dup detection

        this._log = log('ui');
        installDiagnostic(this, this._turns, this._eventLog);

        // Expose toggleSysBalloon globally (used by system balloon click handlers)
        window.toggleSysBalloon = _toggleSysBalloon;
    }

    // ── Configuration ─────────────────────────────────────────────────────────

    get _cfg() {
        const p = PERSPECTIVES[this._perspective] || PERSPECTIVES.user;
        return Object.assign({}, this._opts, p);
    }

    // ── Public: messages ──────────────────────────────────────────────────────

    
    appendMessage(role, content, opts = {}) {
        if (role !== 'error' && (!content || !content.trim())) {
            this._log.warn('appendMessage SKIPPED empty/whitespace content', role);
            return $();
        }

        // Remove empty-state placeholder
        this.$container.find('[data-empty-state]').remove();

        // For assistant with timeline metadata, render a finalized thinking bubble first
        if (role === 'assistant' && opts.metadata && opts.metadata.timeline && opts.metadata.timeline.length > 0) {
            this._log.info('appendMessage rendering finalized bubble for assistant, timeline_len=', opts.metadata.timeline.length);
            this._renderFinalizedBubble(opts.metadata.timeline, opts.metadata.thinking_duration);
        }

        const $wrapper = this._renderers.buildMessageBubble(role, content, opts, this._cfg);
        if (!$wrapper || !$wrapper.length) {
            this._log.warn('appendMessage buildMessageBubble returned empty', role);
            return $();
        }
        this.$container.append($wrapper);
        const totalKids = this.$container.children().length;
        this._log.info('appendMessage appended', role, 'totalChildren=', totalKids, 'contentPreview=', String(content||'').slice(0,60));
        this._smartScroll();
        return $wrapper;
    }

    
    clear() {
        this._turns.forEach(turn => turn.dispose('clear'));
        this._turns.clear();
        this.$container.empty();
        this._lastLiveTurnId = null;
    }

    // ── Public: turn management ───────────────────────────────────────────────

    
    beginTurn($anchor) {
        // Dispose any previous non-finalized turn (defensive)
        const cfg = this._cfg;
        const turn = new Turn({
            $anchor,
            $container:      this.$container,
            renderers:       this._renderers,
            agentAvatarUrl:  cfg.agentAvatarUrl,
            assistantAlign:  cfg.assistantAlign,
            agentId:         cfg.agentId || this._opts.agentId || '',
            onPhaseChange:   (t, prev, next) => {
                this._log.info('turn:phase', t.id, prev, '→', next);
                this.$bus.trigger('turn:phase', [{ turnId: t.id, phase: next, prevPhase: prev }]);
                if (next === 'done' || next === 'aborted') {
                    this._turns.delete(t.id);
                    this.$bus.trigger('turn:disposed', [{ turnId: t.id }]);
                }
            },
            onDispose:       (t, reason) => {
                this._turns.delete(t.id);
                this.$bus.trigger('turn:disposed', [{ turnId: t.id, reason }]);
            },
            onTrigger:       (evtName, data) => {
                this._log.debug('turn event', evtName, data);
                this.$bus.trigger(evtName, [data]);
                // agent-state bridge: fire document-level event for tool:executed
                if (evtName === 'tool:executed') {
                    const toolName = data && data.tool;
                    if (['save_plan', 'set_mode', 'update_tasks', 'state'].includes(toolName)) {
                        document.dispatchEvent(new CustomEvent('evonic:agent-state-changed', { detail: data }));
                    }
                }
                if (evtName === 'approval:required') {
                    document.dispatchEvent(new CustomEvent('evonic:approval-required', { detail: data }));
                }
                if (evtName === 'approval:resolved') {
                    document.dispatchEvent(new CustomEvent('evonic:approval-resolved', { detail: data }));
                }
                if (evtName === 'session_clear') {
                    this.clear();
                }
            },
        });

        this._turns.set(turn.id, turn);
        this._log.info('beginTurn', turn.id);
        return turn;
    }

    
    send(text, opts = {}) {
        const $userEl = this.appendMessage('user', text);
        const turn = this.beginTurn($userEl);
        if (opts.transportFactory) {
            const transport = opts.transportFactory(turn.id);
            if (transport) turn.attach(transport);
        }
        this.scrollToBottom();
        return { turn, $userEl };
    }

    // ── Public: replay ────────────────────────────────────────────────────────

    
    replay(entries, opts = {}) {
        const batch = opts.batch !== false;
        let thinkingTurn = opts._persistTurn || null;

        const _finalizeAndAppend = (thinkingRole, content, metadata) => {
            if (thinkingTurn) {
                thinkingTurn.dispose('replay_finalize');
                thinkingTurn = null;
            }
            this.appendMessage(thinkingRole, content, { metadata });
        };

        const _ensureTurn = (afterEl) => {
            if (!thinkingTurn) {
                const $anchor = afterEl || this.$container.find('[data-msg-role="user"]').last();
                thinkingTurn = this.beginTurn($anchor.length ? $anchor : $());
            }
            return thinkingTurn;
        };

        for (const entry of (entries || [])) {
            if (entry.type === 'user') {
                if (thinkingTurn) { thinkingTurn.dispose('replay_user'); thinkingTurn = null; }
                this.appendMessage('user', entry.content || '', { timestamp: entry.ts ? new Date(entry.ts).toISOString() : null });
                continue;
            }
            if (entry.type === 'thinking') {
                _ensureTurn();
                thinkingTurn.ingest({ event: 'thinking', data: { content: entry.content }, seq: 0 });
                continue;
            }
            if (entry.type === 'tool_call') {
                _ensureTurn();
                thinkingTurn.ingest({ event: 'tool_call_started', data: { tool: entry.function, args: entry.params || {}, param_types: {} }, seq: 0 });
                continue;
            }
            if (entry.type === 'tool_output') {
                _ensureTurn();
                let result;
                try { result = JSON.parse(entry.content); } catch(e) { result = { data: entry.content }; }
                thinkingTurn.ingest({ event: 'tool_executed', data: { tool: entry.function, result, error: !!entry.error }, seq: 0 });
                continue;
            }
            if (entry.type === 'intermediate') {
                _ensureTurn();
                thinkingTurn.ingest({ event: 'response_chunk', data: { content: entry.content, is_final: false }, seq: 0 });
                continue;
            }
            if (entry.type === 'final') {
                const meta = entry.metadata || {};
                const hadStreamingTurn = !!thinkingTurn;
                if (thinkingTurn) {
                    thinkingTurn.ingest({ event: 'done', data: { thinking_duration: meta.thinking_duration }, seq: 0 });
                    thinkingTurn = null;
                }
                // If we built the timeline from streaming events, don't also render it
                // from metadata — that would create a duplicate thinking bubble.
                const msgMeta = hadStreamingTurn ? Object.assign({}, meta, { timeline: [] }) : meta;
                this.appendMessage(meta.error ? 'error' : 'assistant', entry.content, { metadata: msgMeta });
                continue;
            }
            if (entry.type === 'error') {
                if (thinkingTurn) { thinkingTurn.ingest({ event: 'done', data: {}, seq: 0 }); thinkingTurn = null; }
                this.appendMessage('error', entry.content || '');
                continue;
            }
            if (entry.type === 'system') {
                this.appendMessage('system', entry.content || '');
                continue;
            }
        }

        if (opts.persistent) {
            // Caller wants to keep the active turn across calls (idle poll)
            return thinkingTurn;
        }

        if (thinkingTurn) {
            thinkingTurn.dispose('replay_end');
        }

        if (batch) this.scrollToBottom();
        return null;
    }

    // ── Public: perspective ───────────────────────────────────────────────────

    
    setPerspective(perspective) {
        if (!PERSPECTIVES[perspective]) return;
        this._perspective = perspective;
        const p = PERSPECTIVES[perspective];

        this.$container.find('[data-msg-role]').each(function() {
            const role = $(this).attr('data-msg-role');
            const isUser = role === 'user';
            const isRight = isUser ? (p.userAlign === 'right') : (p.assistantAlign === 'right');
            const newAlign = isRight
                ? 'items-end md:items-start md:justify-end'
                : 'items-start md:justify-start';
            $(this).removeClass('items-end items-start md:items-start md:justify-end md:justify-start justify-start justify-end').addClass(newAlign);
        });
    }

    // ── Public: hooks ─────────────────────────────────────────────────────────

    on(event, handler) {
        this.$bus.on(event, handler);
        return this;
    }

    off(event, handler) {
        this.$bus.off(event, handler);
        return this;
    }

    trigger(event, data) {
        this.$bus.trigger(event, [data]);
    }

    // ── Public: containers & scroll ───────────────────────────────────────────

    scrollToBottom() {
        const c = this.$container[0];
        if (c) c.scrollTop = c.scrollHeight;
    }

    isNearBottom(threshold = 300) {
        const c = this.$container[0];
        if (!c) return true;
        return c.scrollHeight - c.scrollTop - c.clientHeight < threshold;
    }

    batchRender(fn) {
        try { fn(); } finally {}
        this.scrollToBottom();
    }

    // ── Public: queued message indicators ────────────────────────────────────

    markLastUserBubbleQueued($msgEl) {
        // Accept an explicit element or fall back to the last user-role message
        const $last = ($msgEl && $msgEl.length) ? $msgEl : this.$container.find('[data-msg-role="user"]').last();
        if (!$last.length || $last.find('.queued-indicator').length) return;
        const isRight = $last.hasClass('md:justify-end') || $last.hasClass('justify-end');
        const $indicator = $('<div class="queued-indicator flex items-center gap-1 text-[10px] text-gray-400 mt-0.5 px-1">').addClass(isRight ? 'justify-end' : 'justify-start');
        $indicator.html('<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg><span>Queued</span>');
        $last.find('div').first().append($indicator);
    }

    markQueuedAsDelivered() {
        this.$container.find('.queued-indicator').each(function() {
            const $el = $(this);
            setTimeout(() => {
                $el.css('transition', 'opacity 0.5s').css('opacity', '0');
                setTimeout(() => $el.remove(), 500);
            }, 2000);
        });
    }

    clearContainer() {
        this.clear();
    }

    // ── Public: compatibility with v1 call sites ──────────────────────────────

    
    showThinkingIndicator(startTs, insertAfterEl) {
        // Anchor to the explicit element if given, otherwise the very last child so the
        // thinking bubble always appears at the end (not anchored to last user message,
        // which may be mid-conversation when called without an explicit anchor).
        const $anchor = insertAfterEl ? $(insertAfterEl) : this.$container.children().last();
        const turn = this.beginTurn($anchor);
        if (startTs) turn._startTime = startTs;
        this._lastLiveTurnId = turn.id; // track for duplicate-bubble detection
        // A new thinking bubble starting means the agent is now processing any queued
        // message — clear the "Queued" badge regardless of how we got here (turn:split,
        // restoreActiveReasoning after /stop, idle poll, etc.).
        this.markQueuedAsDelivered();
        return turn;
    }

    
    removeThinkingIndicator(turnOrId) {
        const turn = typeof turnOrId === 'string' ? this._turns.get(turnOrId) : turnOrId;
        if (turn && turn.dispose) turn.dispose('remove');
    }

    
    appendTimelineEntry(turnOrId, ev) {
        const turn = typeof turnOrId === 'string' ? this._turns.get(turnOrId) : turnOrId;
        if (!turn) return;
        let event = ev.type;
        if (ev.type === 'thinking')    event = 'thinking';
        if (ev.type === 'tool_call')   event = 'tool_call_started';
        if (ev.type === 'tool_result') event = 'tool_executed';
        if (ev.type === 'response')    { turn.ingest({ event: 'response_chunk', data: { content: ev.content, is_final: true }, seq: 0 }); return; }
        if (ev.type === 'retry')       event = 'retry';
        turn.ingest({ event, data: ev, seq: 0 });
    }

    
    finalizeThinkingBubble(turnOrId, duration) {
        const turn = typeof turnOrId === 'string' ? this._turns.get(turnOrId) : turnOrId;
        if (!turn) return;
        turn.ingest({ event: 'done', data: { thinking_duration: duration }, seq: 0 });
    }

    
    getTimelineEntryCount(turnOrId) {
        const turn = typeof turnOrId === 'string' ? this._turns.get(turnOrId) : turnOrId;
        if (!turn || !turn.$timeline) return 0;
        return turn.$timeline.find('.timeline-entry').length;
    }

    
    clearActiveSpinner() {
        this._turns.forEach(t => { if (!t._finalized) t.dispose('clear_spinner'); });
    }

    
    connectThinkingStream(url, turnOrId, opts = {}) {
        const turn = typeof turnOrId === 'string' ? this._turns.get(turnOrId) : turnOrId;
        if (!turn) return null;

        const agentIdMatch = url.match(/\/agents\/([^/?]+)\//);
        const agentId = agentIdMatch ? agentIdMatch[1] : '';
        const afterSeq = parseInt(new URL(url, window.location.origin).searchParams.get('after') || '0', 10);

        const adapter = new SSEAdapter(url, { agentId, afterSeq });
        turn.attach(adapter);

        // Wire onSplit / onDone callbacks from v1 callers
        if (opts.onSplit) {
            const origTrigger = turn._onTrigger;
            turn._onTrigger = (evtName, data) => {
                origTrigger(evtName, data);
                if (evtName === 'turn:split') {
                    // Always anchor after the CURRENT last user message in the DOM.
                    // opts.userMsgEl is the message that started this turn, but the
                    // split is triggered by a newly injected message that was appended
                    // to the container AFTER opts.userMsgEl. Using the stale anchor
                    // would insert the new bubble above the injected message.
                    const $lastUser = this.$container.find('[data-msg-role="user"]').last();
                    const $anchor = $lastUser.length ? $lastUser : (opts.userMsgEl ? $(opts.userMsgEl) : turn.$anchor);
                    const newTurn = this.beginTurn($anchor);
                    this._lastLiveTurnId = newTurn.id;
                    this.markQueuedAsDelivered();
                    // Re-route the SSE adapter to the new turn so subsequent events
                    // are not silently dropped by the finalized old turn's ingest guard.
                    adapter._handler = (evt) => newTurn.ingest(evt);
                    opts.onSplit(newTurn);
                }
            };
        }
        if (opts.onDone) {
            // Fire when the turn reaches done/aborted phase
            const doneHandler = (e, payload) => {
                if (payload && payload.turnId === turn.id) {
                    this.$bus.off('turn:disposed', doneHandler);
                    opts.onDone(turn);
                }
            };
            this.$bus.on('turn:disposed', doneHandler);
        }

        return adapter;
    }

    
    closeStream() {
        // No-op: transports are stopped via turn.dispose()
    }

    
    hasActiveStream() {
        for (const [, turn] of this._turns) {
            if (!turn._finalized) return true;
        }
        return false;
    }

    
    appendApprovalCard(turnOrId, data, agentId) {
        const turn = typeof turnOrId === 'string' ? this._turns.get(turnOrId) : turnOrId;
        if (!turn) return;
        turn._agentId = agentId || turn._agentId;
        turn._addApprovalCard(data);
    }

    
    resolveApprovalCard(data) {
        this._turns.forEach(turn => turn._resolveApprovalCard(data));
    }

    
    setApprovalPendingState(turnOrId, pending) {
        const turn = typeof turnOrId === 'string' ? this._turns.get(turnOrId) : turnOrId;
        if (!turn) return;
        turn._updateSpinner(pending ? 'awaiting_approval' : 'thinking');
    }

    
    resolveThinkingIndicator(turnOrId, timeline, duration) {
        const turn = typeof turnOrId === 'string' ? this._turns.get(turnOrId) : turnOrId;
        if (!turn) return;
        for (const ev of timeline) this.appendTimelineEntry(turn, ev);
        this.finalizeThinkingBubble(turn, duration);
    }

    
    renderThinkingBubble(timeline, duration) {
        const $anchor = this.$container.find('[data-msg-role="user"]').last();
        const turn = this.beginTurn($anchor);
        this.resolveThinkingIndicator(turn, timeline, duration);
    }

    // ── Internal ──────────────────────────────────────────────────────────────

    _renderFinalizedBubble(timeline, duration) {
        if (this._lastLiveTurnId) {
            const $existing = this.$container.find(`[data-turn-id="${CSS.escape(this._lastLiveTurnId)}"]`);
            this._lastLiveTurnId = null;
            if ($existing.length) return; // SSE already rendered the thinking bubble
        }
        const $anchor = this.$container.find('[data-msg-role="user"]').last();
        const turn = this.beginTurn($anchor);
        for (const ev of timeline) this.appendTimelineEntry(turn, ev);
        this.finalizeThinkingBubble(turn, duration);
    }

    _smartScroll() {
        const c = this.$container[0];
        if (!c) return;
        if (c.scrollHeight - c.scrollTop - c.clientHeight < 300) c.scrollTop = c.scrollHeight;
    }

}

// ── toggleSysBalloon global (needed by system balloon click handlers) ─────────

function _toggleSysBalloon(sysId) {
    const el = document.querySelector('[data-sys-id="' + sysId + '"]');
    if (!el) return;
    const full = el.querySelector('.sys-balloon-full');
    const preview = el.querySelector('.sys-balloon-content');
    const chevron = el.querySelector('.sys-chevron');
    if (!full) return;

    const isCollapsed = full.style.display === 'none' || full.style.display === '';
    if (isCollapsed) {
        if (preview) preview.style.display = 'none';
        full.style.display = 'block';
        full.style.maxHeight = '0';
        full.style.transition = 'max-height 0.25s ease';
        requestAnimationFrame(() => { full.style.maxHeight = full.scrollHeight + 'px'; });
        if (chevron) { chevron.style.transition = 'transform 0.2s ease'; chevron.style.transform = 'rotate(180deg)'; }
    } else {
        full.style.maxHeight = full.scrollHeight + 'px';
        full.style.transition = 'max-height 0.25s ease';
        requestAnimationFrame(() => { full.style.maxHeight = '0'; });
        full.addEventListener('transitionend', function handler() {
            full.removeEventListener('transitionend', handler);
            full.style.display = 'none';
            if (preview) preview.style.display = '';
        });
        if (chevron) { chevron.style.transition = 'transform 0.2s ease'; chevron.style.transform = ''; }
    }
}

// ── Assign to window for non-module page code ─────────────────────────────────

window.ChatUI = ChatUI;
window.SSEAdapter = SSEAdapter;
window.PollingAdapter = PollingAdapter;
window.ReplayAdapter = ReplayAdapter;

log('ui').info('chat-ui v2 loaded');

})();
