Size: 142.1 KB Modified: 9/07/2026 2:53 AM
let connection = null;
let currentConversationId = '';
let isStreaming = false;
let streamVisibleOutputStarted = false;
let allMessagesDiv = document.getElementById('messages');
let loadedMessageCount = 0;
let loadedOldestMessageIndex = 0;
let pendingMessage = null; // text queued while waiting for conversation creation
let reconnectTimer = null;
let reconnectAttempt = 0;
let viewportResizeTimer = null;
let streamResumeCheckTimer = null;
let scrollPinToken = 0;
let inputFocusBottomPinUntil = 0;
let outboundQueue = loadOutboundQueue();
let queueProcessing = false;
let appVersion = window.__kaiChatVersion || '';
let appVersionCheck = null;
let updateReloading = false;
let conversationOpenTimer = null;
let activePersona = 'Kai';
let availablePersonas = [];
let activeCompactionMessage = null;
let activeCompactionId = null;
let activeMemorySweepId = null;
let activeToolCalls = {};
let activeToolCallHost = null;
let activeAssistantTurn = null;
let svgRenderSources = {};
let svgRenderCounter = 0;
let svgLightboxKeyHandler = null;
let memoryUpdatePollTimer = null;
let memoryUpdateBusy = false;
let kaiNotificationRegistration = null;
let kaiForegroundGraceUntil = 0;
let kaiAnyClientForegroundUntil = 0;
let kaiForegroundHeartbeatTimer = null;
let kaiForegroundReportInFlight = false;
let kaiForegroundReportQueued = false;
let kaiForegroundReportLastActive = null;
const PAGE_SIZE = 50;
const DRAFT_KEY = 'kaichat.currentDraft';
const KAI_NOTIFICATIONS_KEY = 'kaichat.notifications.enabled';
const PERSONA_QUERY_KEY = 'persona';

function getInput() { return document.getElementById('messageInput'); }
function getSendBtn() { return document.getElementById('sendBtn'); }
function getStopBtn() { return document.getElementById('stopBtn'); }

function normalizePersona(value) {
    var candidate = String(value || '').trim();
    return candidate || 'Kai';
}

function getConversationUrl(id, personaOverride) {
    var persona = normalizePersona(personaOverride || activePersona);
    if (id) return '/?convo=' + encodeURIComponent(id) + '&persona=' + encodeURIComponent(persona || 'Kai');
    return '/?persona=' + encodeURIComponent(persona || 'Kai');
}

function getConversationApiUrl(conversationId) {
    var base = conversationId ? '/api/conversations/' + encodeURIComponent(conversationId) : '/api/conversations';
    return base + '?persona=' + encodeURIComponent(activePersona || 'Kai');
}

function bootstrapApp() {
    ensurePersonaSwitcherElement();
    var isChatPage = !!document.getElementById('messages');
    var params = new URLSearchParams(window.location.search);
    var convoId = params.get('convo') || '';
    activePersona = normalizePersona(params.get('persona'));
    if (!params.get('persona')) {
        try {
            activePersona = normalizePersona(localStorage.getItem('kaichat.activePersona'));
        } catch (e) {
            activePersona = normalizePersona(activePersona);
        }
    }

    var personaBootstrap = initializePersonaState()
        .catch(function () { return null; });
    setupViewportHandling();
    setupAppLifecycleHandlers();

    // Ensure persona and conversation list are initialized in a consistent order.
    Promise.all([personaBootstrap])
        .then(function () {
            loadConversations();
        })
        .catch(function () {
            loadConversations();
        });
    // Check test mode from API on page load
    if (isChatPage) {
        fetch('/api/settings/testmode')
            .then(function (r) { return r.json(); })
            .then(function (data) {
                var badge = document.getElementById('testModeBadge');
                if (badge) badge.style.display = data.testMode ? '' : 'none';
            })
            .catch(function () {});
    }
    if (isChatPage) {
        setupInputHandlers();
        restoreDraft(convoId);
        setInputState(true);
        showSystemMessage('Connecting to server...');
        if (outboundQueue.length > 0) {
            showSystemMessage(outboundQueue.length === 1 ? '1 message queued' : outboundQueue.length + ' messages queued');
        }
        setupConnection();
    } else {
        // Sub-pages: set up a lightweight connection just for sidebar conversation list updates
        setupSidebarConnection();
    }

    setupMemoryUpdateControls();
    setupKaiNotifications();

    Promise.all([personaBootstrap])
        .then(function () {
            if (convoId) {
                loadConversation(convoId);
            }
        })
        .catch(function () {
            if (convoId) {
                loadConversation(convoId);
            }
        });

    window.addEventListener('beforeunload', saveCurrentDraft);
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', function () {
        bootstrapApp();
    });
} else {
    bootstrapApp();
}

function isChatPage() {
    return !!document.getElementById('messages');
}

function ensurePersonaSwitcherElement() {
    if (document.getElementById('personaSwitcher')) return;

    var topBar = document.querySelector('.top-bar');
    if (!topBar) return;

    var label = document.createElement('label');
    label.htmlFor = 'personaSwitcher';
    label.className = 'sr-only';
    label.textContent = 'Persona';

    var select = document.createElement('select');
    select.id = 'personaSwitcher';
    select.className = 'top-bar-persona-select';
    select.title = 'Current persona';
    select.onchange = function () { onPersonaSwitcherChange(this.value); };

    var option = document.createElement('option');
    option.value = 'Kai';
    option.textContent = 'Kai';
    select.appendChild(option);

    // Insert before title, matching markup order used by layout template.
    var title = topBar.querySelector('#conversationTitle');
    if (title && title.parentNode) {
        topBar.insertBefore(label, title);
        topBar.insertBefore(select, title);
    } else {
        topBar.appendChild(label);
        topBar.appendChild(select);
    }
}

function extractPersonasFromMarkup() {
    var selector = document.getElementById('personaSwitcher');
    if (!selector || !selector.options || selector.options.length === 0) return [];

    var seen = {};
    var fallbackPersonas = [];

    for (var i = 0; i < selector.options.length; i++) {
        var option = selector.options[i];
        var value = normalizePersona(option.value || option.textContent || option.label);
        if (!value) continue;

        var key = value.toLowerCase();
        if (seen[key]) continue;
        seen[key] = true;

        fallbackPersonas.push({
            name: option.textContent || value,
            key: value,
            hasConversations: false,
            conversationCount: 0
        });
    }

    return fallbackPersonas;
}

function getCurrentReloadTarget() {
    return currentConversationId ? getConversationUrl(currentConversationId) : window.location.href;
}

function refreshPersonaSwitcher(persona) {
    var selector = document.getElementById('personaSwitcher');
    if (!selector) return;

    activePersona = normalizePersona(persona || activePersona);
    var options = availablePersonas.length
        ? availablePersonas
        : [{ name: 'Kai', key: 'Kai' }];

    selector.innerHTML = options
        .map(function (item) {
            var value = normalizePersona(item.key || item.name || item);
            var label = item.name || value;
            var escapedValue = escapeHtml(value);
            var escapedLabel = escapeHtml(label);
            return '<option value="' + escapedValue + '">' + escapedLabel + '</option>';
        })
        .join('');
    selector.value = activePersona;
    if (selector.value !== activePersona) {
        var fallback = document.createElement('option');
        fallback.value = activePersona;
        fallback.textContent = activePersona;
        selector.appendChild(fallback);
        selector.value = activePersona;
        }
}

function resolveAvailablePersona(value) {
    var normalized = normalizePersona(value);

    // If the persona isn't in the catalog yet, just return the normalized name.
    if (!Array.isArray(availablePersonas)) return normalized || 'Kai';

    for (var i = 0; i < availablePersonas.length; i++) {
        var item = availablePersonas[i];
        var candidate = normalizePersona(item.key || item.name || item);
        if (candidate.toLowerCase() === normalized.toLowerCase()) {
            return candidate;
        }
    }

    // Not found in catalog: fall back to the first persona with conversations.
    var fallback = getFirstPersonaWithConversation();
    if (fallback) return normalizePersona(fallback);
    return normalized || 'Kai';
}

function hasConversationData(item) {
    if (!item) return false;
    if (item.hasConversations === true) return true;

    var count = Number(item.conversationCount || 0);
    return count > 0;
}

function getFirstPersonaWithConversation() {
    if (!Array.isArray(availablePersonas) || availablePersonas.length === 0) return '';

    for (var i = 0; i < availablePersonas.length; i++) {
        if (hasConversationData(availablePersonas[i])) {
            return availablePersonas[i].key || availablePersonas[i].name || availablePersonas[i];
        }
    }

    return '';
}

function applyPersonaToNavigation(persona, forceUrl) {
    var normalized = resolveAvailablePersona(persona);
    var didChange = normalized !== normalizePersona(activePersona);
    activePersona = normalized;

    try {
        localStorage.setItem('kaichat.activePersona', activePersona);
    } catch (e) {}

    refreshPersonaSwitcher(activePersona);

    if (isChatPage() && forceUrl) {
        var nextUrl = getConversationUrl(currentConversationId || '', activePersona);
        if (window.location.pathname === '/' && window.location.href !== nextUrl) {
            window.history.replaceState({}, document.title, nextUrl);
        }
    }

    return didChange;
}

function initializePersonaState() {
    return loadPersonas()
        .then(function () {
            return applyPersonaToNavigation(activePersona);
        })
        .catch(function () {
            availablePersonas = [{ name: 'Kai', key: 'Kai' }];
            return applyPersonaToNavigation(activePersona);
        });
}

function loadPersonas() {
    var selector = document.getElementById('personaSwitcher');
    if (!selector) return Promise.resolve();

    selector.disabled = true;
    return fetch('/api/personas')
        .then(function (response) {
            if (!response.ok) throw new Error('Unable to load personas.');
            return response.json();
        })
        .then(function (data) {
            if (Array.isArray(data)) {
                availablePersonas = data;
            } else {
                availablePersonas = extractPersonasFromMarkup();
                if (!availablePersonas.length) {
                    availablePersonas = [{ name: 'Kai', key: 'Kai' }];
                }
            }
        })
        .catch(function () {
            availablePersonas = extractPersonasFromMarkup();
            if (!availablePersonas.length) {
                availablePersonas = [{ name: 'Kai', key: 'Kai' }];
            }
        })
        .finally(function () {
            selector.disabled = false;
        });
}

function onPersonaSwitcherChange(persona) {
    if (!persona) return;
    var nextPersona = normalizePersona(persona);
    if (nextPersona === activePersona) return;

    activePersona = nextPersona;
    try { localStorage.setItem('kaichat.activePersona', activePersona); } catch (e) {}

    var selector = document.getElementById('personaSwitcher');
    if (selector) selector.value = activePersona;

    if (!isChatPage()) {
        return;
    }

    var targetConversationId = currentConversationId || (new URLSearchParams(window.location.search).get('convo') || '');
    if (!targetConversationId) {
        var newUrl = getConversationUrl('', activePersona);
        if (window.location.pathname === '/' && window.location.href === new URL(newUrl, window.location.origin).href) return;
        window.location.href = newUrl;
        return;
    }

    fetch('/api/conversations/' + encodeURIComponent(targetConversationId) + '?persona=' + encodeURIComponent(activePersona))
        .then(function (response) {
            if (response.ok) {
                window.location.href = getConversationUrl(targetConversationId, activePersona);
                return;
            }
            window.location.href = getConversationUrl('', activePersona);
        })
        .catch(function () {
            window.location.href = getConversationUrl('', activePersona);
        });
}

function cacheBustedUrl(targetUrl) {
    var url = new URL(targetUrl || window.location.href, window.location.origin);
    url.searchParams.set('_kc_update', Date.now().toString());
    return url.toString();
}

function isServerUpdateAvailable() {
    if (!appVersion || updateReloading) return Promise.resolve(false);
    if (appVersionCheck) return appVersionCheck;

    var request = fetch('/api/app/version?_=' + Date.now(), {
        cache: 'no-store',
        headers: { 'Cache-Control': 'no-cache' }
    })
        .then(function (r) {
            if (!r.ok) return false;
            return r.json();
        })
        .then(function (data) {
            var serverVersion = data && data.version ? String(data.version) : '';
            return !!serverVersion && serverVersion !== String(appVersion);
        })
        .catch(function () { return false; });

    appVersionCheck = request.then(function (result) {
        appVersionCheck = null;
        return result;
    }, function () {
        appVersionCheck = null;
        return false;
    });

    return appVersionCheck;
}

function checkForServerUpdate(targetUrl) {
    return isServerUpdateAvailable().then(function (updateAvailable) {
        if (updateAvailable) reloadForServerUpdate(targetUrl);
        return updateAvailable;
    });
}

function reloadForServerUpdate(targetUrl) {
    if (updateReloading) return;
    updateReloading = true;
    saveCurrentDraft();
    saveOutboundQueue();
    setConnectionStatus('reconnecting', 'Updating', 'Refreshing KaiChat...');
    showSystemMessage('KaiChat updated. Refreshing...');
    setTimeout(function () {
        window.location.replace(cacheBustedUrl(targetUrl || getCurrentReloadTarget()));
    }, 80);
}

function saveCurrentDraft() {
    var input = getInput();
    if (!input) return;

    var text = input.value || '';
    try {
        if (!text) {
            localStorage.removeItem(DRAFT_KEY);
            return;
        }

        localStorage.setItem(DRAFT_KEY, JSON.stringify({
            conversationId: currentConversationId || new URLSearchParams(window.location.search).get('convo') || '',
            text: text,
            savedAt: new Date().toISOString()
        }));
    } catch (e) {}
}

function restoreDraft(preferredConversationId) {
    var input = getInput();
    if (!input || input.value) return;

    try {
        var raw = localStorage.getItem(DRAFT_KEY);
        if (!raw) return;

        var draft = JSON.parse(raw);
        if (!draft || !draft.text) return;
        if ((draft.conversationId || '') !== (preferredConversationId || '')) return;

        input.value = draft.text;
        resizeInput(input);
    } catch (e) {}
}

function clearCurrentDraft() {
    try { localStorage.removeItem(DRAFT_KEY); } catch (e) {}
}

function resizeInput(input) {
    if (!input) return;
    input.style.height = 'auto';
    input.style.height = Math.min(input.scrollHeight, 120) + 'px';
}

function setupViewportHandling() {
    if (navigator.virtualKeyboard) {
        try { navigator.virtualKeyboard.overlaysContent = false; } catch (e) {}
    }

    updateAppViewportHeight();

    window.addEventListener('resize', function () {
        var keepMessagesPinned = shouldKeepMessagesPinnedForViewport();
        updateAppViewportHeight();
        queueViewportSettledScroll(keepMessagesPinned);
    });
    window.addEventListener('orientationchange', function () {
        var keepMessagesPinned = shouldKeepMessagesPinnedForViewport();
        updateAppViewportHeight();
        queueViewportSettledScroll(keepMessagesPinned);
    });

    if (window.visualViewport) {
        window.visualViewport.addEventListener('resize', function () {
            var keepMessagesPinned = shouldKeepMessagesPinnedForViewport();
            updateAppViewportHeight();
            queueViewportSettledScroll(keepMessagesPinned);
        });
        window.visualViewport.addEventListener('scroll', function () {
            var keepMessagesPinned = shouldKeepMessagesPinnedForViewport();
            updateAppViewportHeight();
            queueViewportSettledScroll(keepMessagesPinned);
        });
    }
}

function setupAppLifecycleHandlers() {
    markKaiForegroundActive();
    startKaiForegroundHeartbeat();

    window.addEventListener('focus', function () {
        markKaiForegroundActive();
        scheduleStreamingResumeCheck('focus');
    });

    window.addEventListener('blur', function () {
        if (document.hidden) markKaiForegroundInactive();
    });

    window.addEventListener('pageshow', function () {
        markKaiForegroundActive();
        scheduleStreamingResumeCheck('pageshow');
    });

    window.addEventListener('pagehide', function () {
        markKaiForegroundInactive();
    });

    document.addEventListener('visibilitychange', function () {
        if (!document.hidden) {
            markKaiForegroundActive();
            scheduleStreamingResumeCheck('visible');
        } else {
            markKaiForegroundInactive();
        }
    });
}

function markKaiForegroundActive() {
    kaiForegroundGraceUntil = Date.now() + 15000;
    reportKaiForegroundState(true);
}

function markKaiForegroundInactive() {
    kaiForegroundGraceUntil = 0;
    reportKaiForegroundState(false);
}

function startKaiForegroundHeartbeat() {
    if (kaiForegroundHeartbeatTimer) return;
    kaiForegroundHeartbeatTimer = setInterval(function () {
        if (isKaiPageForeground()) {
            markKaiForegroundActive();
        }
    }, 10000);
}

function isKaiPageForeground() {
    if (document.visibilityState === 'visible' || document.hidden === false) return true;
    if (typeof document.hasFocus === 'function' && document.hasFocus()) return true;
    return Date.now() < kaiForegroundGraceUntil;
}

function isAnyKaiClientForeground() {
    return isKaiPageForeground() || Date.now() < kaiAnyClientForegroundUntil;
}

function reportKaiForegroundState(active) {
    kaiForegroundReportLastActive = !!active;
    if (!isConnected()) {
        kaiForegroundReportQueued = true;
        return;
    }

    if (kaiForegroundReportInFlight) {
        kaiForegroundReportQueued = true;
        return;
    }

    kaiForegroundReportInFlight = true;
    kaiForegroundReportQueued = false;
    connection.invoke('ReportClientForeground', kaiForegroundReportLastActive)
        .catch(function () {})
        .finally(function () {
            kaiForegroundReportInFlight = false;
            if (kaiForegroundReportQueued) {
                reportKaiForegroundState(kaiForegroundReportLastActive);
            }
        });
}

function flushKaiForegroundState() {
    if (!isConnected()) return;
    reportKaiForegroundState(isKaiPageForeground());
}

function shouldSuppressKaiNotification() {
    if (isAnyKaiClientForeground()) return Promise.resolve(true);
    if (!isConnected()) return Promise.resolve(false);

    return connection.invoke('HasForegroundClient')
        .then(function (active) {
            if (active) kaiAnyClientForegroundUntil = Date.now() + 20000;
            return !!active || isKaiPageForeground();
        })
        .catch(function () {
            return isKaiPageForeground();
        });
}

function updateAppViewportHeight() {
    var viewport = window.visualViewport;
    var height = viewport && viewport.height ? viewport.height : window.innerHeight;
    if (!height) return;
    document.documentElement.style.setProperty('--app-height', Math.round(height) + 'px');
}

function queueViewportSettledScroll(keepMessagesPinned) {
    if (!isChatPage()) return;
    if (viewportResizeTimer) clearTimeout(viewportResizeTimer);
    viewportResizeTimer = setTimeout(function () {
        viewportResizeTimer = null;
        if (keepMessagesPinned || window.__stickToBottom || shouldPinBottomForFocusedInput()) {
            scrollBottom(true);
        }
    }, 80);
}

function isTouchLikeDevice() {
    return !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
}

function isMessageInputFocused() {
    var input = getInput();
    return !!input && document.activeElement === input;
}

function shouldPinBottomForFocusedInput() {
    return isMessageInputFocused() && Date.now() < inputFocusBottomPinUntil;
}

function shouldKeepMessagesPinnedForViewport() {
    return isChatPage() && (window.__stickToBottom || isAtBottom(180) || shouldPinBottomForFocusedInput());
}

function pinBottomForInputFocus() {
    if (!isChatPage()) return;

    var mobileFocus = isTouchLikeDevice();
    var wasPinned = window.__stickToBottom || isAtBottom(180);
    if (!mobileFocus && !wasPinned) return;

    inputFocusBottomPinUntil = Date.now() + 1500;
    scrollBottom(true);
    queueViewportSettledScroll(true);

    [120, 280, 600, 1000, 1450].forEach(function (delay) {
        setTimeout(function () {
            if (shouldPinBottomForFocusedInput()) scrollBottom(true);
        }, delay);
    });
}

function shouldAutoFocusInput() {
    return !isTouchLikeDevice();
}

function focusInputIfAppropriate() {
    var input = getInput();
    if (input && shouldAutoFocusInput()) input.focus();
}

function setConnectionStatus(state, text, detail) {
    var bar = document.getElementById('statusBar');
    var textEl = document.getElementById('statusText');
    var detailEl = document.getElementById('statusDetail');
    if (!bar || !textEl || !detailEl) return;

    bar.dataset.state = state;
    textEl.textContent = text;
    detailEl.textContent = detail || '';
}

function setupMemoryUpdateControls() {
    var panel = document.getElementById('memoryUpdatePanel');
    if (!panel) return;

    var btn = document.getElementById('memoryUpdateForceBtn');
    if (btn) {
        btn.addEventListener('click', function () {
            forceMemoryUpdate();
        });
    }
    var reconcileBtn = document.getElementById('memoryReconcileAllBtn');
    if (reconcileBtn) {
        reconcileBtn.addEventListener('click', function () {
            reconcileAllMemory();
        });
    }
    var resetBtn = document.getElementById('memoryUpdateResetFuseBtn');
    if (resetBtn) {
        resetBtn.addEventListener('click', function () {
            resetMemoryUpdateFuseAndRun();
        });
    }

    refreshMemoryUpdateStatus(true);
    if (memoryUpdatePollTimer) clearInterval(memoryUpdatePollTimer);
    memoryUpdatePollTimer = setInterval(function () {
        refreshMemoryUpdateStatus(true);
    }, 30000);
}

function refreshMemoryUpdateStatus(silent) {
    if (!document.getElementById('memoryUpdatePanel')) return Promise.resolve();

    return fetch('/api/memory/update/status?_=' + Date.now(), {
        cache: 'no-store',
        headers: { 'Cache-Control': 'no-cache' }
    })
        .then(function (r) {
            if (!r.ok) throw new Error('Status ' + r.status);
            return r.json();
        })
        .then(function (data) {
            updateMemoryUpdatePanel(data);
            return data;
        })
        .catch(function (err) {
            if (!silent) setMemoryUpdatePanelError(err && err.message ? err.message : String(err || 'Status failed'));
        });
}

function forceMemoryUpdate() {
    if (memoryUpdateBusy) return;
    memoryUpdateBusy = true;
    setMemoryUpdatePanelBusy('Starting...');

    fetch('/api/memory/update/force', {
        method: 'POST',
        cache: 'no-store',
        headers: { 'Cache-Control': 'no-cache' }
    })
        .then(function (r) {
            if (!r.ok) throw new Error('Force run failed: ' + r.status);
            return r.json();
        })
        .then(function (data) {
            if (data && data.status) updateMemoryUpdatePanel(data.status, data.message);
            setTimeout(function () { refreshMemoryUpdateStatus(true); }, 1000);
            setTimeout(function () { refreshMemoryUpdateStatus(true); }, 5000);
        })
        .catch(function (err) {
            setMemoryUpdatePanelError(err && err.message ? err.message : String(err || 'Force run failed'));
        })
        .finally(function () {
            memoryUpdateBusy = false;
            refreshMemoryUpdateStatus(true);
        });
}

function resetMemoryUpdateFuseAndRun() {
    if (memoryUpdateBusy) return;
    memoryUpdateBusy = true;
    setMemoryUpdatePanelBusy('Resetting fuse and running...');

    fetch('/api/memory/update/reset-and-run', {
        method: 'POST',
        cache: 'no-store',
        headers: { 'Cache-Control': 'no-cache' }
    })
        .then(function (r) {
            if (!r.ok) throw new Error('Fuse reset run failed: ' + r.status);
            return r.json();
        })
        .then(function (data) {
            if (data && data.status) updateMemoryUpdatePanel(data.status, data.message);
            setTimeout(function () { refreshMemoryUpdateStatus(true); }, 1000);
            setTimeout(function () { refreshMemoryUpdateStatus(true); }, 5000);
        })
        .catch(function (err) {
            setMemoryUpdatePanelError(err && err.message ? err.message : String(err || 'Fuse reset failed'));
        })
        .finally(function () {
            memoryUpdateBusy = false;
            refreshMemoryUpdateStatus(true);
        });
}

function reconcileAllMemory() {
    if (memoryUpdateBusy) return;
    memoryUpdateBusy = true;
    setMemoryUpdatePanelBusy('Starting full reconciliation...');

    fetch('/api/memory/update/reconcile-all', {
        method: 'POST',
        cache: 'no-store',
        headers: { 'Cache-Control': 'no-cache' }
    })
        .then(function (r) {
            if (!r.ok) throw new Error('Full reconciliation failed: ' + r.status);
            return r.json();
        })
        .then(function (data) {
            if (data && data.status) updateMemoryUpdatePanel(data.status, data.message);
            setTimeout(function () { refreshMemoryUpdateStatus(true); }, 1000);
            setTimeout(function () { refreshMemoryUpdateStatus(true); }, 5000);
        })
        .catch(function (err) {
            setMemoryUpdatePanelError(err && err.message ? err.message : String(err || 'Full reconciliation failed'));
        })
        .finally(function () {
            memoryUpdateBusy = false;
            refreshMemoryUpdateStatus(true);
        });
}

function updateMemoryUpdatePanel(data, transientMessage) {
    var stateEl = document.getElementById('memoryUpdateState');
    var detailEl = document.getElementById('memoryUpdateDetail');
    var btn = document.getElementById('memoryUpdateForceBtn');
    var reconcileBtn = document.getElementById('memoryReconcileAllBtn');
    var resetBtn = document.getElementById('memoryUpdateResetFuseBtn');
    if (!stateEl || !detailEl) return;
    var fuseTripped = data && data.fuseIsTripped;

    var state = 'Waiting';
    var stateClass = 'waiting';
    if (data && data.isRunning) {
        state = 'Running';
        stateClass = 'running';
    } else if (fuseTripped) {
        state = 'Fuse tripped';
        stateClass = 'warn';
    } else if (data && data.lastError) {
        state = 'Error';
        stateClass = 'error';
    } else if (data && data.isDue) {
        state = 'Due';
        stateClass = 'due';
    } else if (data && !data.latestPendingUserMessageUtc) {
        state = 'Idle';
        stateClass = 'idle';
    }

    stateEl.className = 'memory-update-state ' + stateClass;
    stateEl.textContent = state;
    detailEl.textContent = transientMessage || buildMemoryUpdateDetail(data);

    if (btn) btn.disabled = memoryUpdateBusy || !!(data && data.isRunning);
    if (reconcileBtn) reconcileBtn.disabled = memoryUpdateBusy || !!(data && data.isRunning);
    if (resetBtn) {
        resetBtn.style.display = fuseTripped ? '' : 'none';
        resetBtn.disabled = memoryUpdateBusy || !!(data && data.isRunning);
    }
}

function setMemoryUpdatePanelBusy(text) {
    var stateEl = document.getElementById('memoryUpdateState');
    var detailEl = document.getElementById('memoryUpdateDetail');
    var btn = document.getElementById('memoryUpdateForceBtn');
    var reconcileBtn = document.getElementById('memoryReconcileAllBtn');
    var resetBtn = document.getElementById('memoryUpdateResetFuseBtn');
    if (stateEl) {
        stateEl.className = 'memory-update-state running';
        stateEl.textContent = 'Running';
    }
    if (detailEl) detailEl.textContent = text || '';
    if (btn) btn.disabled = true;
    if (reconcileBtn) reconcileBtn.disabled = true;
    if (resetBtn) resetBtn.disabled = true;
}

function setMemoryUpdatePanelError(message) {
    var stateEl = document.getElementById('memoryUpdateState');
    var detailEl = document.getElementById('memoryUpdateDetail');
    var btn = document.getElementById('memoryUpdateForceBtn');
    var reconcileBtn = document.getElementById('memoryReconcileAllBtn');
    var resetBtn = document.getElementById('memoryUpdateResetFuseBtn');
    if (stateEl) {
        stateEl.className = 'memory-update-state error';
        stateEl.textContent = 'Error';
    }
    if (detailEl) detailEl.textContent = message || 'Memory status failed';
    if (btn) btn.disabled = false;
    if (reconcileBtn) reconcileBtn.disabled = false;
    if (resetBtn && resetBtn.style.display !== 'none') resetBtn.disabled = false;
}

function buildMemoryUpdateDetail(data) {
    if (!data) return '';
    if (data.isRunning) {
        return 'Started ' + formatMemoryClock(data.lastRunStartedUtc) + ' - ' + (data.lastRunStatus || 'Running memory update...');
    }
    if (data.fuseIsTripped) {
        var failureRate = (data.fuseConsecutiveFailureCount || 0) + '/' + (data.fuseFailureThreshold || 0);
        var tripped = data.fuseTrippedUtc ? (' (since ' + formatMemoryClock(data.fuseTrippedUtc) + ')') : '';
        var reason = data.fuseLastFailureReason ? (' Last failure: ' + data.fuseLastFailureReason) : '';
        return 'Fuse tripped after ' + failureRate + ' consecutive failures' + tripped + '. ' + reason + ' Press "Reset fuse + run now" to retry.';
    }
    if (data.lastError) {
        return data.lastError;
    }
    if (data.isDue) {
        return 'Ready now' + (data.lastRunStatus ? ' - ' + data.lastRunStatus : '');
    }
    if (data.nextEligibleUpdateUtc) {
        return 'Next ' + formatMemoryClock(data.nextEligibleUpdateUtc) + ' - in ' + formatMemoryTimeSpan(data.delayUntilEligible);
    }
    if (data.lastRunCompletedUtc) {
        return 'Last ' + formatMemoryClock(data.lastRunCompletedUtc) + ' - ' + (data.lastRunStatus || 'Completed');
    }
    if (data.marker && !data.marker.exists) {
        return 'No pending user messages - marker absent';
    }
    if (data.marker && data.marker.exists && !data.marker.parsedUtc) {
        return 'Marker exists but its timestamp is invalid';
    }
    if (data.lastUpdateUtc) {
        return 'No pending user messages newer than marker ' + formatMemoryClock(data.lastUpdateUtc);
    }
    return 'No pending user messages';
}

function formatMemoryClock(value) {
    if (!value) return '--';
    var d = new Date(value);
    if (isNaN(d.getTime())) return '--';
    return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}

function formatMemoryTimeSpan(value) {
    if (!value || typeof value !== 'string') return '--';
    var days = 0;
    var clean = value;
    var dot = clean.indexOf('.');
    if (dot >= 0) {
        var beforeDot = clean.slice(0, dot);
        var afterDot = clean.slice(dot + 1);
        if (beforeDot.indexOf(':') < 0 && afterDot.indexOf(':') >= 0) {
            days = parseInt(beforeDot, 10) || 0;
            clean = afterDot;
        } else {
            clean = beforeDot;
        }
    }
    var parts = clean.split(':').map(function (part) { return parseInt(part, 10) || 0; });
    var hours = parts.length > 2 ? parts[0] : 0;
    var minutes = parts.length > 2 ? parts[1] : (parts[0] || 0);
    if (days > 0) return days + 'd ' + hours + 'h';
    if (hours > 0) return hours + 'h ' + minutes + 'm';
    return Math.max(0, minutes) + 'm';
}

function clearReconnectTimer() {
    if (reconnectTimer) {
        clearTimeout(reconnectTimer);
        reconnectTimer = null;
    }
}

function scheduleReconnect(reason) {
    clearReconnectTimer();
    reconnectAttempt += 1;
    var delay = Math.min(30000, 1000 * Math.pow(2, Math.min(reconnectAttempt, 5)));
    var seconds = Math.ceil(delay / 1000);
    setConnectionStatus('reconnecting', 'Reconnecting', 'Retrying in ' + seconds + 's' + (reason ? ': ' + reason : ''));

    reconnectTimer = setTimeout(function () {
        reconnectTimer = null;
        startHubConnection(false);
    }, delay);
}

function startHubConnection(isInitial) {
    if (!connection) return;
    if (connection.state && connection.state !== 'Disconnected') return;

    var reconnecting = reconnectAttempt > 0;
    setConnectionStatus(reconnecting ? 'reconnecting' : 'connecting', reconnecting ? 'Reconnecting' : 'Connecting', 'Contacting KaiChat...');
    if (isChatPage() && !isStreaming) setInputState(true);

    connection.start().then(function () {
        clearReconnectTimer();
        reconnectAttempt = 0;
        setConnectionStatus('connected', 'Connected', 'KaiChat is ready');
        removeSystemMessage('Connecting to server...');
        removeSystemMessage('Still connecting to server...');
        removeSystemMessage('Reconnecting to server...');
        removeSystemMessage('Connection lost. Reconnecting...');
        removeSystemMessage('Server unreachable. Reconnecting...');
        if (isChatPage() && !isStreaming) {
            setInputState(true);
            focusInputIfAppropriate();
        }
        loadConversations();
        joinCurrentConversation();
        flushKaiForegroundState();
        scheduleStreamingResumeCheck('connected');
        processOutboundQueue();
    }).catch(function (err) {
        var message = err && err.message ? err.message : String(err || 'Connection failed');
        if (isInitial) removeSystemMessage('Connecting to server...');
        setConnectionStatus('offline', 'Offline', message);
        if (isChatPage() && !isStreaming) setInputState(true);
        if (isInitial && isChatPage()) {
            showSystemMessage('Server unreachable. Reconnecting...');
        }
        scheduleReconnect(message);
    });
}

function joinCurrentConversation() {
    if (!isConnected() || !currentConversationId) return;
    connection.invoke('JoinConversation', currentConversationId, activePersona)
        .catch(function (err) {
            showErrorMessage('Could not rejoin stream: ' + normalizeErrorMessage(err));
        });
}

function loadOutboundQueue() {
    try {
        var raw = localStorage.getItem('kaichat.outboundQueue');
        return raw ? JSON.parse(raw) : [];
    } catch (e) {
        return [];
    }
}

function saveOutboundQueue() {
    try {
        localStorage.setItem('kaichat.outboundQueue', JSON.stringify(outboundQueue));
    } catch (e) {}
}

function enqueueOutboundMessage(item, replaceMessageId) {
    var existingIndex = findQueuedMessageIndexForReplacement(item, replaceMessageId);
    if (existingIndex >= 0) {
        outboundQueue[existingIndex] = item;
    } else {
        outboundQueue.push(item);
    }

    saveOutboundQueue();
    showOutboundQueueStatus();
}

function buildOutboundMessage(text, title, msgId, truncateBeforeMessageId, conversationId, persona) {
    return {
        conversationId: conversationId !== undefined ? conversationId : (currentConversationId || ''),
        text: text,
        title: title,
        msgId: msgId || generateId(),
        truncateBeforeMessageId: truncateBeforeMessageId || null,
        persona: normalizePersona(persona || activePersona),
        queuedAt: new Date().toISOString()
    };
}

function findQueuedMessageForRenderedMessage(messageId) {
    if (!messageId) return null;
    for (var i = 0; i < outboundQueue.length; i++) {
        var item = outboundQueue[i];
        if (item && item.msgId === messageId) return item;
    }
    return null;
}

function findQueuedMessageIndexForReplacement(item, replaceMessageId) {
    var conversationId = String(item && item.conversationId || '');
    var persona = normalizePersona(item && item.persona || activePersona);
    var msgId = item && item.msgId;
    var truncateBeforeMessageId = item && item.truncateBeforeMessageId;

    for (var i = 0; i < outboundQueue.length; i++) {
        var queued = outboundQueue[i];
        if (!queued) continue;
        if (String(queued.conversationId || '') !== conversationId) continue;
        if (normalizePersona(queued.persona || activePersona) !== persona) continue;

        if (replaceMessageId && queued.msgId === replaceMessageId) return i;
        if (replaceMessageId && queued.truncateBeforeMessageId === replaceMessageId) return i;
        if (msgId && queued.msgId === msgId) return i;
        if (truncateBeforeMessageId && queued.truncateBeforeMessageId === truncateBeforeMessageId) return i;
    }

    return -1;
}

function removeOutboundQueueStatusMessages() {
    if (!allMessagesDiv) return;
    var msgs = allMessagesDiv.querySelectorAll('.message.system');
    for (var i = 0; i < msgs.length; i++) {
        var text = msgs[i].textContent || '';
        if (text === 'Message queued' || text === '1 message queued' || /^\d+ messages queued$/.test(text)) {
            msgs[i].remove();
        }
    }
}

function showOutboundQueueStatus() {
    removeOutboundQueueStatusMessages();
    if (outboundQueue.length === 0) return;
    showSystemMessage(outboundQueue.length === 1 ? 'Message queued' : outboundQueue.length + ' messages queued');
}

function processOutboundQueue() {
    if (!isChatPage() || queueProcessing || isStreaming || !isConnected() || outboundQueue.length === 0) return;

    queueProcessing = true;
    isServerUpdateAvailable().then(function (updateAvailable) {
        if (updateAvailable) {
            queueProcessing = false;
            reloadForServerUpdate(getCurrentReloadTarget());
            return;
        }

        processNextOutboundMessage();
    }).catch(function () {
        processNextOutboundMessage();
    });
}

function processNextOutboundMessage() {
    if (!isChatPage() || isStreaming || !isConnected() || outboundQueue.length === 0) {
        queueProcessing = false;
        return;
    }

    var item = outboundQueue.shift();
    saveOutboundQueue();
    var persona = normalizePersona(item.persona || activePersona);
    setStreamingState(true);
    removeOutboundQueueStatusMessages();
    removeSystemMessage('Sending queued message...');
    showSystemMessage('Sending queued message...');

    var conversationId = item.conversationId || currentConversationId || '';
    var title = conversationId ? null : item.title;
    ensureQueuedMessageRendered(item);
    connection.invoke('SendMessage', conversationId, item.text, title, item.msgId, item.truncateBeforeMessageId || null, persona)
        .then(function () {
            queueProcessing = false;
            removeSystemMessage('Sending queued message...');
        })
        .catch(function (err) {
            outboundQueue.unshift(item);
            saveOutboundQueue();
            queueProcessing = false;
            setStreamingState(false);
            handleError(err);
        });
}

function ensureQueuedMessageRendered(item) {
    if (!allMessagesDiv || hasRenderedUserMessage(item.msgId)) return;
    appendUserMsg(item.text, item.msgId);
    removeWelcome();
}

function findRenderedUserMessage(msgId) {
    if (!msgId || !allMessagesDiv) return false;
    var messages = allMessagesDiv.querySelectorAll('.message.user');
    for (var i = 0; i < messages.length; i++) {
        if (messages[i].dataset.msgId === msgId) return messages[i];
    }
    return null;
}

function hasRenderedUserMessage(msgId) {
    return !!findRenderedUserMessage(msgId);
}

function updateRenderedUserMessageFromSaved(msg) {
    var msgId = msg && (msg.id || msg.Id || '');
    var rendered = findRenderedUserMessage(msgId);
    if (!rendered) return false;
    setStoredTimestamp(rendered, msg);
    return true;
}

function createHubConnection() {
    if (typeof window.signalR === 'undefined') return null;

    var options = {};
    if (signalR.HttpTransportType) {
        // Remote tunnels/reverse proxies often mishandle WebSocket upgrades.
        // SSE/long-polling are slower but much more reliable over plain HTTP proxies.
        options.transport = signalR.HttpTransportType.ServerSentEvents | signalR.HttpTransportType.LongPolling;
    }

    return new signalR.HubConnectionBuilder()
        .withUrl('/chatHub', options)
        .withAutomaticReconnect()
        .build();
}

function setupConnection() {
    connection = createHubConnection();
    if (!connection) {
        removeSystemMessage('Connecting to server...');
        setConnectionStatus('offline', 'Offline', 'Chat connection script failed to load');
        showSystemMessage('Chat connection script failed to load.');
        setInputState(false);
        return;
    }

    connection.on('StreamReplayStarted', function (conversationId) {
        if (currentConversationId && conversationId && conversationId !== currentConversationId) return;
        resetActiveStreamUi();
        setStreamingState(true);
        removeSystemMessage('Connecting to server...');
        removeSystemMessage('Reconnecting to server...');
        removeSystemMessage('Still connecting to server...');
    });

    connection.on('UserMessageSaved', function (conversationId, msg) {
        if (!allMessagesDiv) return;
        if (currentConversationId && conversationId && conversationId !== currentConversationId) return;

        if (!currentConversationId && conversationId) {
            currentConversationId = conversationId;
            document.getElementById('currentConversationId').value = conversationId;
            history.replaceState(null, '', getConversationUrl(conversationId));
        }

        var msgId = msg && (msg.id || msg.Id || '');
        if (updateRenderedUserMessageFromSaved(msg)) return;

        appendUserMsg(getMessageContent(msg), msgId, msg);
        removeWelcome();
    });

    connection.on('KaiForegroundClientsChanged', function (active) {
        kaiAnyClientForegroundUntil = active ? Date.now() + 20000 : 0;
    });

    connection.on('ReceiveToken', function (token) {
        markStreamVisibleOutputStarted();
        removeSystemMessage('Thinking...');
        var shouldAutoScroll = isAtBottom();
        var lastMsg = ensureAssistantResponseBlock();
        if (!lastMsg) return;

        // Accumulate raw text (with [TOOL_CALL:...] markers) for eventual rendering.
        lastMsg.dataset.raw += token;

        // Live rendering: throttle to every 200ms so we don't re-render on every token.
        var now = Date.now();
        if (!lastMsg._lastRender || now - lastMsg._lastRender > 200) {
            lastMsg._lastRender = now;
            var displayText = sanitizeAssistantDisplayText(lastMsg.dataset.translated || lastMsg.dataset.raw);
            lastMsg.innerHTML = renderMarkdown(displayText);
        }
        scrollBottom(shouldAutoScroll);
    });

    connection.on('StreamStart', function () {
        setStreamingState(true);
        streamVisibleOutputStarted = false;
        activeToolCallHost = null;
        ensureAssistantTurn();
        window.__curThinkingContent = null;
    });

    connection.on('ReceiveThinking', function (raw) {
        markStreamVisibleOutputStarted();
        removeSystemMessage('Thinking...');
        var shouldAutoScroll = isAtBottom(180) || window.__stickToBottom;
        var div = ensureThinkingContentBlock();
        if (!div) return;

        div.textContent += raw;
        scrollThinkingContentToBottom(div);
        scrollBottom(shouldAutoScroll);
    });

    connection.on('ThinkingTranslated', function (translated) {
        var pre = window.__curThinkingContent || document.querySelector('.thinking-block:last-of-type .thinking-content');
        if (pre) {
            pre.textContent = translated;
            var summary = pre.closest('details').querySelector('summary');
            if (summary) summary.textContent = 'Thinking (translated from Chinese)';
        }
    });

    connection.on('ThinkingTranslationStarted', function (attempt, maxAttempts) {
        if (attempt && maxAttempts) {
            updateStreamRetryNotice('Translating thinking ' + attempt + '/' + maxAttempts);
        }

        var pre = window.__curThinkingContent || document.querySelector('.thinking-block:last-of-type .thinking-content');
        if (pre) {
            pre.textContent = '';
            var summary = pre.closest('details').querySelector('summary');
            if (summary) summary.textContent = 'Thinking (translated from Chinese)';
            scrollThinkingContentToBottom(pre);
        }
    });

    connection.on('ThinkingTranslationDelta', function (token) {
        var pre = window.__curThinkingContent || document.querySelector('.thinking-block:last-of-type .thinking-content');
        if (pre) {
            var shouldAutoScroll = isAtBottom(180) || window.__stickToBottom;
            pre.textContent += token;
            scrollThinkingContentToBottom(pre);
            scrollBottom(shouldAutoScroll);
        }
    });

    connection.on('ResponseTranslated', function (translated) {
        var lastMsg = getLastOpenAssistantResponseMessage();
        if (lastMsg) {
            var shouldAutoScroll = isAtBottom();
            // Keep data-raw intact (it holds [TOOL_CALL:...] markers) so
            // renderLastMessageMarkdown can still render tool call blocks.
            lastMsg.dataset.translated = translated;
            delete lastMsg.dataset.translationActive;
            delete lastMsg.dataset.translationOriginal;
            delete lastMsg.dataset.translationConsumed;
            lastMsg.innerHTML = renderMarkdown(translated);
            lastMsg.dataset.rendered = '1';
            scrollBottom(shouldAutoScroll);
        }
    });

    connection.on('ResponseTranslationStarted', function (attempt, maxAttempts) {
        markStreamVisibleOutputStarted();
        var lastMsg = ensureAssistantForTranslation();
        if (!lastMsg) return;

        if (attempt && maxAttempts) {
            updateStreamRetryNotice((attempt > 1 ? 'Retrying' : 'Translating') + ' response translation ' + attempt + '/' + maxAttempts + (attempt > 1 ? ': Chinese output detected' : ''));
        }

        var shouldAutoScroll = isAtBottom();
        lastMsg.dataset.translationOriginal = lastMsg.dataset.translationOriginal || lastMsg.dataset.raw || lastMsg.textContent || '';
        lastMsg.dataset.translationConsumed = '0';
        lastMsg.dataset.translated = '';
        lastMsg.dataset.translationActive = '1';
        lastMsg._lastTranslationRender = 0;
        delete lastMsg.dataset.rendered;
        lastMsg.innerHTML = renderTranslationProgress('', lastMsg.dataset.translationOriginal, 0);
        scrollBottom(shouldAutoScroll);
    });

    connection.on('ResponseTranslationDelta', function (token) {
        var lastMsg = ensureAssistantForTranslation();
        if (!lastMsg) return;

        var shouldAutoScroll = isAtBottom();
        lastMsg.dataset.translated = (lastMsg.dataset.translated || '') + token;
        var original = lastMsg.dataset.translationOriginal || lastMsg.dataset.raw || '';
        var consumed = parseInt(lastMsg.dataset.translationConsumed || '0', 10);
        consumed += Math.max(1, Math.ceil(countSymbols(token) / 4));
        consumed = Math.min(countSymbols(original), consumed);
        lastMsg.dataset.translationConsumed = String(consumed);
        delete lastMsg.dataset.rendered;

        var now = Date.now();
        if (!lastMsg._lastTranslationRender || now - lastMsg._lastTranslationRender > 100) {
            lastMsg._lastTranslationRender = now;
            lastMsg.innerHTML = renderTranslationProgress(lastMsg.dataset.translated, original, consumed);
        }
        scrollBottom(shouldAutoScroll);
    });

    connection.on('ResponseTranslationRetry', function (attempt, maxAttempts) {
        updateStreamRetryNotice('Retrying response translation ' + attempt + '/' + maxAttempts + ': Chinese output detected');
    });

    connection.on('ThinkingTranslationRetry', function (attempt, maxAttempts) {
        updateStreamRetryNotice('Retrying thinking translation ' + attempt + '/' + maxAttempts + ': Chinese output detected');
    });

    connection.on('ToolCall', function (toolName, status) {
        markStreamVisibleOutputStarted();
        removeSystemMessage('Thinking...');
        appendToolNotice(status || toolName || 'Tool activity');
    });

    connection.on('ToolCallStarted', function (id, method, path, status, body) {
        markStreamVisibleOutputStarted();
        removeSystemMessage('Thinking...');
        createToolCallCard(id, method, path, status, body || '');
    });

    connection.on('ToolCallResult', function (id, status, result, isError) {
        updateToolCallCard(id, status, result, !!isError);
    });

    connection.on('DiscardAssistantDraft', function () {
        markStreamVisibleOutputStarted();
        removeAssistantDraft();
    });

    connection.on('AssistantRetry', function (attempt, maxAttempts, reason) {
        markStreamVisibleOutputStarted();
        removeAssistantAttemptDraft();
        updateStreamRetryNotice('Retrying response ' + attempt + '/' + maxAttempts + ': ' + (reason || 'Chinese output detected'));
    });

    connection.on('AssistantSegment', function (content) {
        markStreamVisibleOutputStarted();
        appendAssistantSegment(content);
    });

    connection.on('KaiMessageAppended', function (conversationId, msg, source) {
        loadConversations();
        if (isChatPage() && (!currentConversationId || currentConversationId === conversationId)) {
            if (!currentConversationId && conversationId) {
                currentConversationId = conversationId;
                document.getElementById('currentConversationId').value = conversationId;
                history.replaceState(null, '', getConversationUrl(conversationId));
            }

            removeWelcome();
            var state = { assistantTurn: null };
            appendStoredAssistantMessage(allMessagesDiv, state, msg);
            scrollBottom(isAtBottom());
        }

        notifyKaiMessage(conversationId, msg, source || 'assistant');
    });

    connection.on('ReplaceAssistantDraft', function (content) {
        replaceAssistantDraft(content || '');
    });

    connection.on('TokenUpdate', function (promptTokens, completionTokens, combined, compactionPct) {
        var display = document.getElementById('tokenDisplay');
        if (display) {
            display.textContent = promptTokens + ' + ' + completionTokens + ' = ' + combined + ' tokens';
        }
        // Update compaction progress bar
        var barContainer = document.getElementById('compactionBarContainer');
        var barFill = document.getElementById('compactionBarFill');
        var label = document.getElementById('compactionLabel');
        if (barContainer && barFill && label) {
            if (compactionPct !== null && compactionPct !== undefined && compactionPct > 0) {
                barContainer.style.display = '';
                var pct = Math.min(100, compactionPct);
                barFill.style.width = pct + '%';
                label.textContent = pct + '%';
            } else {
                barContainer.style.display = 'none';
            }
        }
    });

    connection.on('SystemMessage', function (message) {
        showSystemMessage(message);
    });

    connection.on('MemorySweepStarted', function (evt) {
        var data = normalizeMemorySweepEvent(evt);
        activeMemorySweepId = data.id;
        renderLiveMemorySweepEvent(data);
    });

    connection.on('MemorySweepStatus', function (evt) {
        updateLiveMemorySweepStatus(normalizeMemorySweepEvent(evt));
    });

    connection.on('MemorySweepComplete', function (evt) {
        completeLiveMemorySweepEvent(normalizeMemorySweepEvent(evt));
        activeMemorySweepId = null;
    });

    connection.on('CompactionStarted', function (evt) {
        var data = normalizeCompactionEvent(evt);
        activeCompactionId = data.id;
        renderLiveCompactionEvent(data);
    });

    connection.on('CompactionStatus', function (evt) {
        updateLiveCompactionStatus(normalizeCompactionEvent(evt));
    });

    connection.on('CompactionThinkingDelta', function (evt) {
        appendLiveCompactionText(normalizeCompactionEvent(evt), 'thinking');
    });

    connection.on('CompactionSummaryDelta', function (evt) {
        appendLiveCompactionText(normalizeCompactionEvent(evt), 'summary');
    });

    connection.on('CompactionComplete', function (evt) {
        completeLiveCompactionEvent(normalizeCompactionEvent(evt));
        activeCompactionId = null;
    });

    connection.on('StreamComplete', function () {
        setStreamingState(false);
        removeSystemMessage('Thinking...');
        // Final render of ALL unrendered assistant messages
        var shouldAutoScroll = isAtBottom();
        renderLastMessageMarkdown();
        // Mark the streaming message as closed so the next response creates a new element
        var lastMsg = getLastAssistantMessage();
        if (lastMsg) {
            lastMsg.dataset.streamClosed = '1';
            if (!lastMsg.dataset.timestamp) {
                lastMsg.dataset.timestamp = formatTimestamp(new Date());
            }
        }
        closeStreamingThinkingBlock();
        activeToolCallHost = null;
        finalizeStreamRetryNotice();
        if (activeAssistantTurn) activeAssistantTurn.dataset.streamClosed = '1';
        activeAssistantTurn = null;
        if (lastMsg && lastMsg.dataset.raw) {
            lastMsg.dataset.notifyId = lastMsg.dataset.notifyId || generateId();
            notifyKaiMessage(currentConversationId, {
                id: lastMsg.dataset.notifyId,
                content: lastMsg.dataset.raw
            }, 'assistant_stream');
        }
        queueProcessing = false;
        scrollBottom(shouldAutoScroll);
        focusInputIfAppropriate();
        loadConversations();
        processOutboundQueue();
    });

    connection.on('StreamCancelled', function () {
        setStreamingState(false);
        queueProcessing = false;
        removeSystemMessage('Thinking...');
        closeStreamingThinkingBlock();
        var container = activeAssistantTurn || allMessagesDiv;
        var last = container ? container.lastElementChild : null;
        if (last && last.classList.contains('assistant') && !last.dataset.streamClosed) last.remove();
        if (activeAssistantTurn && activeAssistantTurn.children.length === 0) activeAssistantTurn.remove();
        activeToolCallHost = null;
        if (activeAssistantTurn) activeAssistantTurn.dataset.streamClosed = '1';
        activeAssistantTurn = null;
        showSystemMessage('Response stopped');
    });

    connection.on('Error', function (error) {
        setStreamingState(false);
        queueProcessing = false;
        removeSystemMessage('Thinking...');
        closeStreamingThinkingBlock();
        activeToolCallHost = null;
        if (activeAssistantTurn) activeAssistantTurn.dataset.streamClosed = '1';
        activeAssistantTurn = null;
        showErrorMessage(error);
        processOutboundQueue();
    });

    connection.on('ConversationCreated', function (conv) {
        currentConversationId = conv.id;
        document.getElementById('currentConversationId').value = conv.id;
        document.getElementById('conversationTitle').textContent = conv.title || 'Kai';
        history.replaceState(null, '', getConversationUrl(conv.id));
        joinCurrentConversation();
        loadConversations();

        // If there's a message queued waiting for this conversation, send it now
        if (pendingMessage) {
            var text = pendingMessage;
            pendingMessage = null;
            removeSystemMessage('Creating conversation...');
            showSystemMessage('Thinking...');
            setStreamingState(true);
            var msgId = generateId();
            appendUserMsg(text, msgId);
            connection.invoke('SendMessage', currentConversationId, text, null, msgId, null, activePersona)
                .catch(function (err) { handleError(err); });
        } else {
            setInputState(!isStreaming);
            if (!isStreaming) focusInputIfAppropriate();
        }
    });

    connection.on('ConversationDeleted', function (id) {
        if (currentConversationId === id) {
            currentConversationId = '';
            document.getElementById('currentConversationId').value = '';
            document.getElementById('conversationTitle').textContent = 'Kai';
            clearMessages();
            showWelcome();
        }
        loadConversations();
    });

    connection.on('ConversationRenamed', function (id, title) {
        if (id === currentConversationId && title) {
            document.getElementById('conversationTitle').textContent = title;
        }
        loadConversations();
    });
    connection.on('ConversationsChanged', function () { loadConversations(); });

    connection.onreconnecting(function () {
        if (!isStreaming) setInputState(true);
        setConnectionStatus('reconnecting', 'Reconnecting', 'Connection interrupted');
        showSystemMessage('Reconnecting to server...');
    });
    connection.onreconnected(function () {
        clearReconnectTimer();
        reconnectAttempt = 0;
        setConnectionStatus('connected', 'Connected', 'KaiChat is ready');
        removeSystemMessage('Reconnecting to server...');
        removeSystemMessage('Still connecting to server...');
        removeSystemMessage('Connection lost. Reconnecting...');
        removeSystemMessage('Server unreachable. Reconnecting...');
        if (!isStreaming) {
            setInputState(true);
            focusInputIfAppropriate();
        }
        loadConversations();
        joinCurrentConversation();
        flushKaiForegroundState();
        scheduleStreamingResumeCheck('reconnected');
        processOutboundQueue();
        showToast('Reconnected', 2000);
    });
    connection.onclose(function () {
        if (!isStreaming) applyStreamingControls(false);
        showSystemMessage('Connection lost. Reconnecting...');
        scheduleReconnect('Connection closed');
    });

    startHubConnection(true);
}

function setupSidebarConnection() {
    // Lightweight SignalR connection for sidebar updates on sub-pages
    connection = createHubConnection();
    if (!connection) {
        setConnectionStatus('offline', 'Offline', 'Chat connection script failed to load');
        return;
    }

    connection.on('ConversationRenamed', function () { loadConversations(); });
    connection.on('ConversationsChanged', function () { loadConversations(); });
    connection.onreconnecting(function () {
        setConnectionStatus('reconnecting', 'Reconnecting', 'Connection interrupted');
    });
    connection.onreconnected(function () {
        clearReconnectTimer();
        reconnectAttempt = 0;
        setConnectionStatus('connected', 'Connected', 'KaiChat is ready');
        loadConversations();
        flushKaiForegroundState();
    });
    connection.onclose(function () {
        scheduleReconnect('Connection closed');
    });

    startHubConnection(true);
}

function setupInputHandlers() {
    const input = getInput();
    input.addEventListener('keydown', function (e) {
        if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
            sendMessage();
        }
    });
    input.addEventListener('input', function () {
        resizeInput(input);
        saveCurrentDraft();
    });
    input.addEventListener('focus', function () {
        updateAppViewportHeight();
        pinBottomForInputFocus();
    });
    input.addEventListener('blur', function () {
        updateAppViewportHeight();
    });
    // Wire Send button click
    getSendBtn().addEventListener('click', function () {
        sendMessage();
    });
    // Wire Stop button click
    getStopBtn().addEventListener('click', function () {
        stopStreaming();
    });
}

function sendMessage() {
    const input = getInput();
    const text = input.value.trim();
    if (!text) return;
    if (isStreaming && !canSendPreTokenSteering()) return;

    input.value = '';
    resizeInput(input);
    clearCurrentDraft();

    if (isStreaming) {
        sendSteeringMessage(text);
        return;
    }

    var msgId = generateId();
    var title = currentConversationId ? null : (text.length > 40 ? text.substring(0, 40) + '...' : text);

    // Render the user message immediately so it's always visible — even if
    // the page reloads for a server update or the connection is offline.
    appendUserMsg(text, msgId);
    removeWelcome();

    isServerUpdateAvailable().then(function (updateAvailable) {
        if (updateAvailable) {
            enqueueOutboundMessage(buildOutboundMessage(text, title, msgId, null, currentConversationId || '', activePersona));
            reloadForServerUpdate(getCurrentReloadTarget());
            return;
        }

        sendPreparedMessage(text, title, msgId, null);
    });
}

function canSendPreTokenSteering() {
    return isStreaming && !streamVisibleOutputStarted && isConnected() && !!currentConversationId;
}

function sendSteeringMessage(text) {
    var msgId = generateId();
    appendUserMsg(text, msgId);
    removeWelcome();
    showSystemMessage('Updating pending response...');
    setStreamingState(true);

    connection.invoke('SendMessage', currentConversationId, text, null, msgId, null, activePersona)
        .then(function () {
            removeSystemMessage('Updating pending response...');
            showSystemMessage('Thinking...');
        })
        .catch(function (err) {
            removeSystemMessage('Updating pending response...');
            handleError(err);
        });
}

function sendPreparedMessage(text, title, msgId, truncateBeforeMessageId) {
    if (!isConnected()) {
        enqueueOutboundMessage(buildOutboundMessage(text, title, msgId, truncateBeforeMessageId, currentConversationId || '', activePersona));
        setInputState(true);
        return;
    }

    showSystemMessage('Thinking...');

    setStreamingState(true);

    connection.invoke('SendMessage', currentConversationId || '', text, title, msgId, truncateBeforeMessageId || null, activePersona)
            .catch(function (err) {
            if (shouldQueueAfterSendError(err)) {
                enqueueOutboundMessage(buildOutboundMessage(text, title, msgId, truncateBeforeMessageId, currentConversationId || '', activePersona));
                recoverAfterQueuedSend(err);
                return;
            }
            handleError(err);
        });
}

function shouldQueueAfterSendError(err) {
    if (!isConnected()) return true;

    var msg = ((err && err.message) ? err.message : String(err || '')).toLowerCase();
    return msg.indexOf('connection') !== -1
        || msg.indexOf('disconnect') !== -1
        || msg.indexOf('timeout') !== -1
        || msg.indexOf('transport') !== -1
        || msg.indexOf('network') !== -1;
}

function recoverAfterQueuedSend(err) {
    var msg = err && err.message ? err.message : String(err || 'Connection interrupted');
    setStreamingState(false);
    queueProcessing = false;
    removeSystemMessage('Thinking...');
    setConnectionStatus('reconnecting', 'Reconnecting', msg);
    if (connection && connection.state === 'Connected') {
        connection.stop().catch(function () {});
    }
    scheduleReconnect(msg);
}

function createAndSend(text) {
    if (!isConnected()) {
        var msgId = generateId();
        appendUserMsg(text, msgId);
        removeWelcome();
        enqueueOutboundMessage(buildOutboundMessage(text, text.length > 40 ? text.substring(0, 40) + '...' : text, msgId, null, '', activePersona));
        return;
    }
    appendUserMsg(text);
    removeWelcome();
    showSystemMessage('Creating conversation...');
    pendingMessage = text;
    var title = text.length > 40 ? text.substring(0, 40) + '...' : text;
    connection.invoke('CreateConversation', title, activePersona)
        .catch(function (err) {
            pendingMessage = null;
            showErrorMessage(err);
        });
}

function stopStreaming() {
    if (!isStreaming) return;
    if (!isConnected()) return;
    connection.invoke('StopStreaming', currentConversationId || null).catch(function () {});
}

function retryLastMessage() {
    var msgs = allMessagesDiv.querySelectorAll('.message.user');
    if (msgs.length === 0) return;
    var last = msgs[msgs.length - 1];
    getInput().value = last.dataset.originalText || last.textContent;
    resizeInput(getInput());
    saveCurrentDraft();
    getInput().focus();
}

function editMessage(btn) {
    var msgDiv = btn.closest('.message.user');
    if (!msgDiv) return;
    var original = msgDiv.dataset.originalText || msgDiv.textContent;
    msgDiv.innerHTML = '<div class="msg-edit-area">'
        + '<textarea class="msg-edit-input">' + escapeHtml(original) + '</textarea>'
        + '<div class="msg-edit-actions">'
        + '<button class="btn btn-small btn-primary" onclick="saveEdit(this)">Save</button>'
        + '<button class="btn btn-small" onclick="cancelEdit(this)">Cancel</button>'
        + '</div></div>';
    msgDiv.querySelector('.msg-edit-input').focus();
}

function saveEdit(btn) {
    var area = btn.closest('.msg-edit-area');
    var textarea = area.querySelector('.msg-edit-input');
    var newText = textarea.value.trim();
    if (!newText) return;
    var msgDiv = btn.closest('.message.user');
    msgDiv.innerHTML = '';
    msgDiv.textContent = newText;
    msgDiv.dataset.originalText = newText;
    addMsgActions(msgDiv, newText);
    resendText(newText, msgDiv);
}

function cancelEdit(btn) {
    var area = btn.closest('.msg-edit-area');
    var msgDiv = area.closest('.message.user');
    var original = msgDiv.dataset.originalText || msgDiv.textContent;
    msgDiv.innerHTML = '';
    msgDiv.textContent = original;
    addMsgActions(msgDiv, original);
}

function resendText(text, afterMsg) {
    if (isStreaming) return;
    // Capture the message ID before removing DOM for server-side truncation
    var msgId = afterMsg.dataset.msgId || '';
    var queuedMessage = findQueuedMessageForRenderedMessage(msgId);
    var truncateBeforeMessageId = queuedMessage ? (queuedMessage.truncateBeforeMessageId || null) : msgId;
    var replaceQueuedMessageId = queuedMessage ? msgId : null;
    // Remove the target message and everything after it (including assistant responses)
    var found = false;
    while (allMessagesDiv.lastChild) {
        if (allMessagesDiv.lastChild === afterMsg) {
            found = true;
            allMessagesDiv.lastChild.remove();
            break;
        }
        allMessagesDiv.lastChild.remove();
    }
    if (!found) return;
    // Re-add the user message before sending
    var newMsgId = generateId();
    appendUserMsg(text, newMsgId);
    removeWelcome();
    if (!isConnected()) {
        enqueueOutboundMessage(
            buildOutboundMessage(text, null, newMsgId, truncateBeforeMessageId, currentConversationId || '', activePersona),
            replaceQueuedMessageId);
        setInputState(true);
        return;
    }
    showSystemMessage('Thinking...');
    setStreamingState(true);
    connection.invoke('SendMessage', currentConversationId, text, null, newMsgId, truncateBeforeMessageId, activePersona)
        .catch(function (err) {
            if (shouldQueueAfterSendError(err)) {
                enqueueOutboundMessage(
                    buildOutboundMessage(text, null, newMsgId, truncateBeforeMessageId, currentConversationId || '', activePersona),
                    replaceQueuedMessageId);
                recoverAfterQueuedSend(err);
                return;
            }
            handleError(err);
        });
}

// --- UI helpers ---

function resetActiveStreamUi() {
    if (!allMessagesDiv) return;

    var streamingThinking = allMessagesDiv.querySelectorAll('.thinking-block[data-streaming="1"]');
    streamingThinking.forEach(function (node) { node.remove(); });

    var assistantMessages = allMessagesDiv.querySelectorAll('.message.assistant');
    assistantMessages.forEach(function (msg) {
        if (!msg.dataset.streamClosed && !msg.dataset.rendered) msg.remove();
    });

    activeToolCalls = {};
    activeToolCallHost = null;
    activeAssistantTurn = null;
    window.__curThinkingContent = null;
    forceScrollBottom();
}

function removeAssistantDraft() {
    if (!allMessagesDiv) return;

    var shouldAutoScroll = isAtBottom();
    closeStreamingThinkingBlock();

    var lastMsg = getLastAssistantResponseMessage();
    if (lastMsg && !lastMsg.dataset.streamClosed) {
        lastMsg.remove();
    }

    window.__curThinkingContent = null;
    scrollBottom(shouldAutoScroll);
}

function removeAssistantAttemptDraft() {
    if (!allMessagesDiv) return;

    var shouldAutoScroll = isAtBottom();
    var turn = activeAssistantTurn || allMessagesDiv;

    var last = turn.lastElementChild;
    if (isAssistantResponseBlock(last) && !last.dataset.streamClosed) {
        last.remove();
        last = turn.lastElementChild;
    }

    if (last && last.classList && last.classList.contains('thinking-block')) {
        last.remove();
    }

    window.__curThinkingContent = null;
    scrollBottom(shouldAutoScroll);
}

function updateStreamRetryNotice(text) {
    if (!allMessagesDiv) return;

    var shouldAutoScroll = isAtBottom();
    var turn = ensureAssistantTurn() || allMessagesDiv;
    var notice = turn.querySelector('.stream-retry-notice[data-active="1"]');
    if (!notice) {
        notice = document.createElement('div');
        notice.className = 'stream-retry-notice';
        notice.dataset.active = '1';
        turn.appendChild(notice);
    }
    notice.textContent = text;
    scrollBottom(shouldAutoScroll);
}

function finalizeStreamRetryNotice() {
    var turn = activeAssistantTurn;
    if (!turn) return;
    var notice = turn.querySelector('.stream-retry-notice[data-active="1"]');
    if (notice) delete notice.dataset.active;
}

function scrollThinkingContentToBottom(node) {
    if (!node) return;
    node.scrollTop = node.scrollHeight;
}

function closeStreamingThinkingBlock() {
    if (!allMessagesDiv) return;

    var streamingThinking = allMessagesDiv.querySelector('.thinking-block[data-streaming="1"]:last-of-type');
    if (!streamingThinking) return;

    var content = streamingThinking.querySelector('.thinking-content');
    if (!content || !content.textContent.trim()) {
        streamingThinking.remove();
    } else {
        delete streamingThinking.dataset.streaming;
    }

    window.__curThinkingContent = null;
}

function isAssistantResponseBlock(node) {
    return !!node
        && node.classList
        && node.classList.contains('message')
        && node.classList.contains('assistant')
        && !node.classList.contains('tool-call-host');
}

function isStreamingThinkingBlock(node) {
    return !!node
        && node.classList
        && node.classList.contains('thinking-block')
        && node.dataset.streaming === '1';
}

function closeOpenAssistantResponseBlock() {
    var turn = activeAssistantTurn && document.body.contains(activeAssistantTurn)
        ? activeAssistantTurn
        : null;
    var last = turn ? turn.lastElementChild : getLastAssistantResponseMessage();
    if (!isAssistantResponseBlock(last) || last.dataset.streamClosed) return;

    last.dataset.streamClosed = '1';
    if (!last.dataset.timestamp) {
        last.dataset.timestamp = formatTimestamp(new Date());
    }
}

function getCurrentThinkingContent() {
    var div = window.__curThinkingContent;
    if (!div || !document.body.contains(div)) return null;

    var block = div.closest('.thinking-block');
    var turn = block ? block.closest('.assistant-turn') : null;
    if (!isStreamingThinkingBlock(block)) return null;
    if (turn && turn.lastElementChild !== block) return null;

    return div;
}

function ensureThinkingContentBlock() {
    var current = getCurrentThinkingContent();
    if (current) return current;

    closeOpenAssistantResponseBlock();
    activeToolCallHost = null;

    var details = createThinkingBlock('', true);
    (ensureAssistantTurn() || allMessagesDiv).appendChild(details);
    var div = details.querySelector('.thinking-content');
    window.__curThinkingContent = div;
    return div;
}

function ensureAssistantResponseBlock() {
    if (!allMessagesDiv) return null;

    var turn = ensureAssistantTurn();
    var lastMsg = turn ? turn.lastElementChild : allMessagesDiv.lastElementChild;
    if (isAssistantResponseBlock(lastMsg) && !lastMsg.dataset.streamClosed) {
        return lastMsg;
    }

    closeStreamingThinkingBlock();
    activeToolCallHost = null;

    var msg = document.createElement('div');
    msg.className = 'message assistant';
    msg.dataset.raw = '';
    (turn || allMessagesDiv).appendChild(msg);
    return msg;
}

function createAssistantTurnElement(closed) {
    var turn = document.createElement('div');
    turn.className = 'assistant-turn';
    if (closed) turn.dataset.streamClosed = '1';
    return turn;
}

function createThinkingBlock(thinking, streaming) {
    var details = document.createElement('details');
    details.className = 'thinking-block';
    details.open = true;
    if (streaming) details.dataset.streaming = '1';

    var summary = document.createElement('summary');
    summary.textContent = 'Thinking...';

    var content = document.createElement('div');
    content.className = 'thinking-content';
    content.textContent = thinking || '';

    details.appendChild(summary);
    details.appendChild(content);
    return details;
}

function ensureAssistantTurn() {
    if (!allMessagesDiv) return null;
    if (activeAssistantTurn && document.body.contains(activeAssistantTurn)) return activeAssistantTurn;

    var last = allMessagesDiv.lastElementChild;
    if (last && last.classList.contains('assistant-turn') && !last.dataset.streamClosed) {
        activeAssistantTurn = last;
        return activeAssistantTurn;
    }

    var turn = createAssistantTurnElement(false);
    allMessagesDiv.appendChild(turn);
    activeAssistantTurn = turn;
    return turn;
}

function appendToolNotice(status) {
    var shouldAutoScroll = isAtBottom();
    var toolDiv = document.createElement('div');
    toolDiv.className = 'message tool-call';
    toolDiv.textContent = status;
    allMessagesDiv.appendChild(toolDiv);
    scrollBottom(shouldAutoScroll);
}

function appendAssistantSegment(content) {
    content = sanitizeAssistantDisplayText(content || '');
    if (!allMessagesDiv || !content || !content.trim()) return;

    removeSystemMessage('Thinking...');
    var shouldAutoScroll = isAtBottom();
    var msg = ensureAssistantResponseBlock();
    if (!msg) return;

    msg.dataset.raw = [msg.dataset.raw || '', content].filter(Boolean).join('\n\n');
    msg.innerHTML = renderMarkdown(sanitizeAssistantDisplayText(msg.dataset.raw));
    msg.dataset.rendered = '1';
    scrollBottom(shouldAutoScroll);
}

function replaceAssistantDraft(content) {
    if (!allMessagesDiv) return;

    var shouldAutoScroll = isAtBottom();
    var lastMsg = getLastAssistantResponseMessage();
    if (!lastMsg || lastMsg.dataset.streamClosed) return;

    var cleanContent = sanitizeAssistantDisplayText(content || '');
    if (!cleanContent.trim()) {
        lastMsg.remove();
        scrollBottom(shouldAutoScroll);
        return;
    }

    lastMsg.dataset.raw = cleanContent;
    lastMsg.innerHTML = renderMarkdown(cleanContent);
    lastMsg.dataset.rendered = '1';
    scrollBottom(shouldAutoScroll);
}

function ensureToolCallHost() {
    if (!allMessagesDiv) return null;

    var turn = ensureAssistantTurn();
    closeStreamingThinkingBlock();
    closeOpenAssistantResponseBlock();

    var msg = document.createElement('div');
    msg.className = 'message assistant tool-call-host';
    msg.dataset.raw = '';
    msg.dataset.rendered = '1';
    msg.dataset.streamClosed = '1';
    msg.dataset.timestamp = formatTimestamp(new Date());
    (turn || allMessagesDiv).appendChild(msg);
    activeToolCallHost = msg;
    return msg;
}

function createToolCallCard(id, method, path, status, parameters) {
    if (!allMessagesDiv) return;

    var shouldAutoScroll = isAtBottom();
    var host = ensureToolCallHost();
    if (!host) return;

    var card = document.createElement('details');
    card.className = 'tool-call-block running';
    card.open = true;
    card.dataset.toolCallId = id || generateId();

    var summary = document.createElement('summary');
    summary.innerHTML =
        '<span class="tool-call-title">' + escapeHtml(method || 'TOOL') + ' ' + escapeHtml(path || '') + '</span>'
        + '<span class="tool-call-state">running</span>';

    var body = document.createElement('div');
    body.className = 'tool-call-body';
    body.innerHTML =
        '<div class="tool-call-status">' + escapeHtml(status || 'Running tool...') + '</div>'
        + '<details class="tool-call-params"><summary>Parameters</summary><pre class="tool-call-params-json"></pre></details>'
        + '<pre class="tool-call-result" style="display:none"></pre>';
    body.querySelector('.tool-call-params-json').textContent = formatToolParameters(parameters || '');

    card.appendChild(summary);
    card.appendChild(body);
    host.appendChild(card);
    activeToolCalls[card.dataset.toolCallId] = card;
    scrollBottom(shouldAutoScroll);
}

function updateToolCallCard(id, status, result, isError) {
    if (!allMessagesDiv) return;

    var card = activeToolCalls[id];
    if (!card) {
        createToolCallCard(id, 'TOOL', '', status || 'Tool result', '');
        card = activeToolCalls[id];
    }
    if (!card) return;

    var shouldAutoScroll = isAtBottom();
    var isSkipped = String(status || '').toLowerCase().indexOf('skipped') >= 0;
    card.classList.remove('running', 'success', 'error', 'skipped');
    card.classList.add(isError ? 'error' : isSkipped ? 'skipped' : 'success');
    card.open = !!isError || isSkipped;

    var state = card.querySelector('.tool-call-state');
    if (state) state.textContent = isError ? 'error' : isSkipped ? 'skipped' : 'done';

    var statusEl = card.querySelector('.tool-call-status');
    if (statusEl) statusEl.textContent = status || (isError ? 'Tool error' : 'Tool complete');

    var resultEl = card.querySelector('.tool-call-result');
    if (resultEl) {
        resultEl.style.display = '';
        resultEl.textContent = formatToolResultPreview(result || '');
    }

    delete activeToolCalls[id];
    scrollBottom(shouldAutoScroll);
}

function formatToolResultPreview(result) {
    var text = String(result || '');
    var max = 12000;
    if (text.length <= max) return text;
    return text.substring(0, max) + '\n\n[Result truncated in live view; full result was provided to Kai and saved in Tool Logs.]';
}

function formatToolParameters(parameters) {
    var text = String(parameters || '').trim();
    if (!text) return '{}';
    try {
        return JSON.stringify(JSON.parse(text), null, 2);
    } catch (_) {
        return text;
    }
}

function sanitizeAssistantDisplayText(text) {
    text = String(text || '');
    if (!text) return '';

    var output = '';
    var searchIndex = 0;
    var header = findToolResultHeader(text, searchIndex);

    while (header) {
        output += text.substring(searchIndex, header.index);
        searchIndex = findToolResultBlockEnd(text, header);
        header = findToolResultHeader(text, searchIndex);
    }

    output += text.substring(searchIndex);
    return normalizeVisibleAssistantText(output);
}

function findToolResultHeader(text, startIndex) {
    var re = /^[ \t]*(?:Tool execution results?\s*\(round\s+\d+\)|PRIVATE TOOL OUTPUT[^\r\n]*)\s*:?[ \t]*(?:\r?\n|$)/gim;
    re.lastIndex = startIndex || 0;
    return re.exec(text);
}

function findToolResultBlockEnd(text, header) {
    var bodyStart = header.index + header[0].length;
    var nextHeader = findToolResultHeader(text, bodyStart);
    var headerText = header[0].toLowerCase();

    if (headerText.indexOf('private tool output') >= 0) {
        var privateEnd = indexOfIgnoreCase(text, 'END PRIVATE TOOL OUTPUT', bodyStart);
        if (privateEnd >= 0 && (!nextHeader || privateEnd < nextHeader.index)) {
            return advancePastWhitespace(text, privateEnd + 'END PRIVATE TOOL OUTPUT'.length);
        }
    }

    var instructionStart = indexOfIgnoreCase(text, 'Use this tool result before answering Raymond.', bodyStart);
    if (instructionStart >= 0 && (!nextHeader || instructionStart < nextHeader.index)) {
        return advancePastWhitespace(text, findToolInstructionEnd(text, instructionStart));
    }

    return nextHeader ? nextHeader.index : text.length;
}

function findToolInstructionEnd(text, instructionStart) {
    var phrases = [
        'Do not keep sampling alternatives just because more files are available. If a tool call was skipped as a repeat, use the earlier result or answer directly instead of trying the same call again.',
        'If this was skipped as a repeat, use the earlier result or answer directly instead of trying the same call again.',
        'If you cannot fix it, explain the problem clearly to Raymond.'
    ];
    var bestEnd = -1;

    phrases.forEach(function (phrase) {
        var index = indexOfIgnoreCase(text, phrase, instructionStart);
        if (index >= 0) bestEnd = Math.max(bestEnd, index + phrase.length);
    });

    if (bestEnd >= 0) return bestEnd;

    var paragraphEnd = text.indexOf('\n\n', instructionStart);
    return paragraphEnd >= 0 ? paragraphEnd : text.length;
}

function indexOfIgnoreCase(text, value, startIndex) {
    return text.toLowerCase().indexOf(value.toLowerCase(), startIndex || 0);
}

function advancePastWhitespace(text, index) {
    while (index < text.length && /\s/.test(text.charAt(index))) index++;
    return index;
}

function normalizeVisibleAssistantText(text) {
    return String(text || '')
        .replace(/[ \t]+(\r?\n)/g, '$1')
        .replace(/(\r?\n){3,}/g, '\n\n')
        .trim();
}

function isToolResultContent(content) {
    return typeof content === 'string' && content.indexOf('Tool execution results') === 0;
}

function createToolMessageElement(msg) {
    var tool = msg.tool || msg.Tool || {};
    var content = msg.content || msg.Content || '';
    var method = tool.method || tool.Method || 'TOOL';
    var path = tool.path || tool.Path || '';
    var status = (tool.status || tool.Status || '').toLowerCase();
    var statusLabel = tool.status || tool.Status || 'tool';
    var round = tool.round || tool.Round || '?';
    var bodyText = tool.body || tool.Body || '';
    var resultText = tool.result || tool.Result || content || '';
    var isError = !!(tool.isError || tool.IsError || status === 'error');
    var isSkipped = status === 'skipped';
    var stateClass = isError ? 'error' : isSkipped ? 'skipped' : 'success';
    var stateLabel = isError ? 'error' : isSkipped ? 'skipped' : 'done';
    var statusText = isError ? 'Tool error' : isSkipped ? 'Tool skipped' : 'Tool complete';
    if (statusLabel && !/^(success|error|skipped)$/i.test(statusLabel)) {
        statusText = statusLabel;
    }

    var host = document.createElement('div');
    host.className = 'message assistant tool-call-host';
    host.dataset.raw = content;
    host.dataset.rendered = '1';
    host.dataset.streamClosed = '1';

    var details = document.createElement('details');
    details.className = 'tool-call-block ' + stateClass;
    details.open = isError || isSkipped;

    var summary = document.createElement('summary');
    summary.innerHTML =
        '<span class="tool-call-title">' + escapeHtml(method) + ' ' + escapeHtml(path) + '</span>'
        + '<span class="tool-call-state">' + escapeHtml(stateLabel) + '</span>';

    var body = document.createElement('div');
    body.className = 'tool-call-body';
    body.innerHTML =
        '<div class="tool-call-status">Round ' + escapeHtml(String(round)) + ' - ' + escapeHtml(statusText) + '</div>'
        + '<details class="tool-call-params"><summary>Parameters</summary><pre class="tool-call-params-json"></pre></details>'
        + '<pre class="tool-call-result"></pre>';
    body.querySelector('.tool-call-params-json').textContent = formatToolParameters(bodyText);
    body.querySelector('.tool-call-result').textContent = formatToolResultPreview(resultText);

    details.appendChild(summary);
    details.appendChild(body);
    host.appendChild(details);
    return host;
}

function createToolResultHistoryElement(content) {
    var host = document.createElement('div');
    host.className = 'message assistant tool-call-host tool-call-history-host';
    host.dataset.raw = content || '';
    host.dataset.rendered = '1';
    host.dataset.streamClosed = '1';

    var details = document.createElement('details');
    details.className = 'tool-call-block history';
    details.open = false;

    var summary = document.createElement('summary');
    summary.innerHTML =
        '<span class="tool-call-title">Tool results</span>'
        + '<span class="tool-call-state">history</span>';

    var body = document.createElement('div');
    body.className = 'tool-call-body';
    body.innerHTML = '<pre class="tool-call-result"></pre>';
    body.querySelector('.tool-call-result').textContent = formatToolResultPreview(content || '');

    details.appendChild(summary);
    details.appendChild(body);
    host.appendChild(details);
    return host;
}

function getLastAssistantMessage() {
    if (!allMessagesDiv) return null;
    var messages = allMessagesDiv.querySelectorAll('.message.assistant');
    return messages.length ? messages[messages.length - 1] : null;
}

function getLastAssistantResponseMessage() {
    if (!allMessagesDiv) return null;
    var messages = allMessagesDiv.querySelectorAll('.message.assistant:not(.tool-call-host)');
    return messages.length ? messages[messages.length - 1] : null;
}

function getLastOpenAssistantResponseMessage() {
    var lastMsg = getLastAssistantResponseMessage();
    return lastMsg && !lastMsg.dataset.streamClosed ? lastMsg : null;
}

function ensureAssistantForTranslation() {
    var lastMsg = getLastOpenAssistantResponseMessage();
    if (lastMsg) return lastMsg;

    return ensureAssistantResponseBlock();
}

function appendUserMsg(text, msgId, savedMessage) {
    var div = document.createElement('div');
    div.className = 'message user';
    div.textContent = text;
    div.dataset.originalText = text;
    div.dataset.msgId = msgId || '';
    var timestamp = savedMessage ? getMessageTimestamp(savedMessage) : null;
    div.dataset.timestamp = timestamp ? formatTimestamp(new Date(timestamp)) : formatTimestamp(new Date());
    allMessagesDiv.appendChild(div);
    addMsgActions(div, text);
    forceScrollBottom();
}

function addMsgActions(div, text) {
    var actions = document.createElement('div');
    actions.className = 'msg-actions';
    actions.innerHTML = '<button class="btn-action" onclick="editMessage(this)" title="Edit">E</button>'
        + '<button class="btn-action" onclick="retryMsg(this)" title="Resend">R</button>';
    div.appendChild(actions);
}

function retryMsg(btn) {
    var msgDiv = btn.closest('.message.user');
    if (!msgDiv) return;
    resendText(msgDiv.dataset.originalText || msgDiv.textContent, msgDiv);
}

function removeWelcome() {
    var w = allMessagesDiv.querySelector('.welcome');
    if (w) w.remove();
}

function formatTimestamp(date) {
    var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
    var d = date.getDay();
    var dayNum = String(date.getDate()).padStart(2, '0');
    var month = String(date.getMonth() + 1).padStart(2, '0');
    var year = date.getFullYear();
    var hours = date.getHours() % 12 || 12;
    var hoursStr = String(hours).padStart(2, '0');
    var mins = String(date.getMinutes()).padStart(2, '0');
    var secs = String(date.getSeconds()).padStart(2, '0');
    var ampm = date.getHours() >= 12 ? 'PM' : 'AM';
    return days[d] + ' ' + dayNum + '/' + month + '/' + year + ' ' + hoursStr + ':' + mins + ':' + secs + ' ' + ampm;
}

function generateId() {
    return Math.random().toString(36).substring(2, 10);
}

// --- conversation loading ---

function loadConversation(id) {
    var targetUrl = getConversationUrl(id);
    checkForServerUpdate(targetUrl).then(function (updateStarted) {
        if (!updateStarted) loadConversationNow(id);
    });
}

function loadConversationNow(id) {
    if (!document.getElementById('messages')) {
        window.location.href = getConversationUrl(id);
        return;
    }
    // During initial bootstrap currentConversationId starts empty and the
    // outbound queue may have already set isStreaming before we get here.
    // Only bail if streaming on a different conversation (user clicked the
    // sidebar mid-stream), not during the initial page load.
    if (isStreaming && currentConversationId && currentConversationId !== id) return;
    currentConversationId = id;
    document.getElementById('currentConversationId').value = id;
    document.getElementById('conversationTitle').textContent = 'Kai';
    loadedMessageCount = 0;
    loadedOldestMessageIndex = 0;
    setInputState(true);
    focusInputIfAppropriate();
    fetch(getConversationApiUrl(id))
        .then(function (r) {
            if (r.status === 404) throw new Error('Conversation not found');
            if (!r.ok) throw new Error('Could not load conversation');
            return r.json();
        })
        .then(function (conv) {
            // Only clear once the authoritative data has arrived, so
            // messages rendered by the outbound queue (or from a
            // previous reconnect) stay visible during the fetch.
            clearMessages();
            document.getElementById('conversationTitle').textContent = conv.title || 'Kai';
            renderMessages(conv.messages, true);
            joinCurrentConversation();
            closeSidebar();
            loadConversations();
        })
        .catch(function (err) {
            if (!err || err.message !== 'Conversation not found') {
                showSystemMessage('Could not load conversation. Reconnecting...');
                scheduleReconnect(err && err.message ? err.message : 'Conversation load failed');
                return;
            }

            // Conversation doesn't exist — redirect to new chat and show toast
            currentConversationId = '';
            document.getElementById('currentConversationId').value = '';
            document.getElementById('conversationTitle').textContent = 'Kai';
            clearMessages();
            showWelcome();
            showToast('Conversation not found — starting new chat', 3000);
            history.replaceState(null, '', getConversationUrl(''));
            closeSidebar();
            loadConversations();
        });
}

function getMessageRole(msg) {
    return String(msg.role || msg.Role || '').toLowerCase();
}

function getMessageContent(msg) {
    return String(msg.content || msg.Content || '');
}

function getMessageThinking(msg) {
    return String(msg.thinking || msg.Thinking || '');
}

function getMessageTimestamp(msg) {
    return msg.timestamp || msg.Timestamp || null;
}

function setStoredTimestamp(element, msg) {
    var timestamp = getMessageTimestamp(msg);
    if (timestamp) {
        element.dataset.timestamp = formatTimestamp(new Date(timestamp));
    }
}

function ensureStoredAssistantTurn(parent, state) {
    if (!state.assistantTurn) {
        state.assistantTurn = createAssistantTurnElement(true);
        parent.appendChild(state.assistantTurn);
    }
    return state.assistantTurn;
}

function closeStoredAssistantTurn(state) {
    state.assistantTurn = null;
}

function appendStoredAssistantMessage(parent, state, msg) {
    var thinking = getMessageThinking(msg);
    var content = sanitizeAssistantDisplayText(getMessageContent(msg));
    if (!thinking.trim() && !content.trim()) return;

    var turn = ensureStoredAssistantTurn(parent, state);
    if (thinking.trim()) {
        turn.appendChild(createThinkingBlock(thinking, false));
    }
    if (!content.trim()) return;

    var div = document.createElement('div');
    div.className = 'message assistant';
    div.dataset.raw = getMessageContent(msg);
    div.dataset.rendered = '1';
    div.dataset.streamClosed = '1';
    div.innerHTML = renderMarkdown(content);
    setStoredTimestamp(div, msg);
    turn.appendChild(div);
}

function appendStoredToolMessage(parent, state, msg) {
    var turn = ensureStoredAssistantTurn(parent, state);
    var toolMessage = createToolMessageElement(msg);
    setStoredTimestamp(toolMessage, msg);
    turn.appendChild(toolMessage);
}

function appendStoredToolHistory(parent, state, msg) {
    var turn = ensureStoredAssistantTurn(parent, state);
    var toolHistory = createToolResultHistoryElement(getMessageContent(msg));
    setStoredTimestamp(toolHistory, msg);
    turn.appendChild(toolHistory);
}

function createStoredStandardMessage(msg, role) {
    var div = document.createElement('div');
    div.className = 'message ' + role;
    var content = getMessageContent(msg);
    if (role === 'user') {
        div.textContent = content;
        div.dataset.originalText = content;
        div.dataset.msgId = msg.id || msg.Id || '';
        addMsgActions(div, content);
    } else {
        div.innerHTML = renderMarkdown(sanitizeAssistantDisplayText(content));
        div.dataset.rendered = '1';
    }
    setStoredTimestamp(div, msg);
    return div;
}

function appendStoredMessage(parent, state, msg) {
    var role = getMessageRole(msg);
    var content = getMessageContent(msg);

    if (role === 'compaction') {
        closeStoredAssistantTurn(state);
        parent.appendChild(createCompactionElementFromMessage(msg, false));
        return;
    }

    if (role === 'memory_sweep') {
        closeStoredAssistantTurn(state);
        parent.appendChild(createMemorySweepElementFromMessage(msg, false));
        return;
    }

    if (role === 'system' && isToolResultContent(content)) {
        appendStoredToolHistory(parent, state, msg);
        return;
    }

    if (role === 'tool') {
        appendStoredToolMessage(parent, state, msg);
        return;
    }

    if (role === 'assistant') {
        appendStoredAssistantMessage(parent, state, msg);
        return;
    }

    closeStoredAssistantTurn(state);
    parent.appendChild(createStoredStandardMessage(msg, role || 'system'));
}

function renderMessages(messages, scrollToBottom) {
    var total = messages.length;
    var renderedMessages = messages;
    if (total > PAGE_SIZE) {
        var btn = document.createElement('div');
        btn.className = 'load-more';
        btn.innerHTML = '<button class="btn btn-link" onclick="loadEarlierMessages()">&#8593; Load earlier messages</button>';
        allMessagesDiv.appendChild(btn);
        renderedMessages = messages.slice(-PAGE_SIZE);
    }
    loadedOldestMessageIndex = Math.max(0, total - renderedMessages.length);
    var state = { assistantTurn: null };
    renderedMessages.forEach(function (msg) {
        appendStoredMessage(allMessagesDiv, state, msg);
    });
    loadedMessageCount = renderedMessages.length;
    if (scrollToBottom) forceScrollBottom();
}
function loadEarlierMessages() {
    if (!currentConversationId || loadedOldestMessageIndex <= 0) return;

    fetch(getConversationApiUrl(currentConversationId))
        .then(function (r) {
            if (!r.ok) throw new Error('Could not load earlier messages');
            return r.json();
        })
        .then(function (conv) {
            var previousScrollHeight = allMessagesDiv.scrollHeight;
            var total = conv.messages.length;
            var endIdx = Math.min(loadedOldestMessageIndex, total);
            var toLoad = Math.min(PAGE_SIZE, endIdx);
            if (toLoad <= 0) return;

            var startIdx = endIdx - toLoad;
            var fragment = document.createDocumentFragment();
            var btn = allMessagesDiv.querySelector('.load-more');
            if (btn) btn.remove();
            if (startIdx > 0) {
                var lb = document.createElement('div');
                lb.className = 'load-more';
                lb.innerHTML = '<button class="btn btn-link" onclick="loadEarlierMessages()">&#8593; Load earlier messages</button>';
                fragment.appendChild(lb);
            }
            var state = { assistantTurn: null };
            conv.messages.slice(startIdx, endIdx).forEach(function (msg) {
                appendStoredMessage(fragment, state, msg);
            });
            allMessagesDiv.insertBefore(fragment, allMessagesDiv.firstChild);
            loadedMessageCount += toLoad;
            loadedOldestMessageIndex = startIdx;
            allMessagesDiv.scrollTop += allMessagesDiv.scrollHeight - previousScrollHeight;
        })
        .catch(function (err) {
            showErrorMessage(err || 'Could not load earlier messages');
        });
}

// --- state ---

function setInputState(enabled) {
    var input = getInput();
    var sendBtn = getSendBtn();
    if (input) input.disabled = !enabled;
    if (sendBtn) sendBtn.disabled = !enabled;
}

function applyStreamingControls(streaming) {
    if (!isChatPage()) return;
    var input = getInput();
    var sendBtn = getSendBtn();
    var stopBtn = getStopBtn();
    var canSteer = streaming && !streamVisibleOutputStarted && isConnected() && !!currentConversationId;

    if (input) input.disabled = false;
    if (sendBtn) sendBtn.disabled = streaming && !canSteer;
    if (sendBtn) sendBtn.style.display = streaming && !canSteer ? 'none' : '';
    if (stopBtn) stopBtn.style.display = streaming ? '' : 'none';
}

function setStreamingState(streaming) {
    isStreaming = !!streaming;
    if (!isStreaming) streamVisibleOutputStarted = false;
    applyStreamingControls(isStreaming);
}

function markStreamVisibleOutputStarted() {
    if (!isStreaming) return;
    streamVisibleOutputStarted = true;
    applyStreamingControls(true);
}

function isStopButtonVisible() {
    var stopBtn = getStopBtn();
    return !!(stopBtn && stopBtn.style.display !== 'none' && !stopBtn.hidden);
}

function scheduleStreamingResumeCheck(reason) {
    if (!isChatPage()) return;
    if (streamResumeCheckTimer) clearTimeout(streamResumeCheckTimer);
    applyStreamingControls(isStreaming);
    streamResumeCheckTimer = setTimeout(function () {
        streamResumeCheckTimer = null;
        reconcileStreamingState(reason);
    }, 900);
}

function reconcileStreamingState(reason) {
    if (!isChatPage()) return;
    applyStreamingControls(isStreaming);
    if (!isConnected()) return;

    if (!currentConversationId) {
        if (isStreaming || isStopButtonVisible()) clearStaleStreamingUi(false);
        return;
    }

    connection.invoke('IsConversationStreaming', currentConversationId)
        .then(function (active) {
            if (active) {
                setStreamingState(true);
                return;
            }

            if (isStreaming || isStopButtonVisible()) {
                clearStaleStreamingUi(true);
            }
        })
        .catch(function () {
            applyStreamingControls(isStreaming);
        });
}

function clearStaleStreamingUi(reloadConversation) {
    setStreamingState(false);
    queueProcessing = false;
    removeSystemMessage('Thinking...');
    closeStreamingThinkingBlock();
    activeToolCallHost = null;
    if (activeAssistantTurn) activeAssistantTurn.dataset.streamClosed = '1';
    activeAssistantTurn = null;
    window.__curThinkingContent = null;
    renderLastMessageMarkdown();
    if (reloadConversation && currentConversationId) {
        loadConversationNow(currentConversationId);
    }
    processOutboundQueue();
}

function showSystemMessage(text) {
    if (!allMessagesDiv) return;
    removeSystemMessage(text);
    var shouldAutoScroll = isAtBottom();
    var div = document.createElement('div');
    div.className = 'message system';
    div.dataset.systemMessage = text;
    div.textContent = text;
    allMessagesDiv.appendChild(div);
    scrollBottom(shouldAutoScroll);
}

function showErrorMessage(error) {
    if (!allMessagesDiv) return;
    var text = normalizeErrorMessage(error);
    var systemText = 'Error: ' + text;
    removeSystemMessage(systemText);
    var shouldAutoScroll = isAtBottom();
    var div = document.createElement('div');
    div.className = 'message system error-message';
    div.dataset.systemMessage = systemText;
    div.setAttribute('role', 'alert');

    var title = document.createElement('div');
    title.className = 'error-message-title';
    title.textContent = 'Error';

    var body = document.createElement('div');
    body.className = 'error-message-body';
    body.textContent = text;

    div.appendChild(title);
    div.appendChild(body);
    allMessagesDiv.appendChild(div);
    scrollBottom(shouldAutoScroll);
}

function normalizeErrorMessage(error) {
    var text = '';
    if (error && error.message) {
        text = error.message;
    } else if (typeof error === 'string') {
        text = error;
    } else if (error) {
        text = String(error);
    }

    text = (text || 'Something went wrong.').trim();
    return text.replace(/^Error:\s*/i, '') || 'Something went wrong.';
}

function removeSystemMessage(text) {
    if (!allMessagesDiv) return;
    var msgs = allMessagesDiv.querySelectorAll('.message.system');
    for (var i = 0; i < msgs.length; i++) {
        if (msgs[i].dataset.systemMessage === text || msgs[i].textContent === text) msgs[i].remove();
    }
}

function isConnected() {
    return connection && connection.state === 'Connected';
}

function handleError(err) {
    var msg = normalizeErrorMessage(err);
    if (msg.indexOf('error on the server') !== -1 && connection && connection.state !== 'Connected') {
        showSystemMessage('Server unreachable. Reconnecting...');
        scheduleReconnect(msg);
    } else {
        showErrorMessage(msg);
    }
    setStreamingState(false);
    queueProcessing = false;
    processOutboundQueue();
}

function isAtBottom(margin) {
    if (!allMessagesDiv) return true;
    var threshold = margin === undefined ? 96 : margin;
    return allMessagesDiv.scrollHeight - allMessagesDiv.scrollTop - allMessagesDiv.clientHeight < threshold;
}

function setMessagesScrollToBottom() {
    if (!allMessagesDiv) return;
    allMessagesDiv.scrollTop = allMessagesDiv.scrollHeight;
}

function scrollBottom(force) {
    if (!allMessagesDiv) return;
    if (force === false) return;
    if (force === true || window.__stickToBottom || isAtBottom()) {
        setMessagesScrollToBottom();
    }
}

function forceScrollBottom() {
    if (!allMessagesDiv) return;
    var token = ++scrollPinToken;
    window.__stickToBottom = true;

    function runFrame(remaining) {
        if (token !== scrollPinToken) return;
        setMessagesScrollToBottom();
        if (remaining > 0) {
            requestAnimationFrame(function () { runFrame(remaining - 1); });
        } else {
            setTimeout(function () {
                if (token !== scrollPinToken) return;
                setMessagesScrollToBottom();
                window.__stickToBottom = false;
            }, 90);
        }
    }

    runFrame(3);
}

function newChat() {
    checkForServerUpdate('/').then(function (updateStarted) {
        if (!updateStarted) window.location.href = getConversationUrl('');
    });
}

function deleteConversation(id) {
    clearPendingConversationOpen();
    if (!confirm('Delete this conversation?')) return;
    if (isConnected()) {
        connection.invoke('DeleteConversation', id, activePersona).catch(function (err) {
            showErrorMessage(err);
        });
    } else {
        // Fallback for sub-pages without active SignalR
        fetch(getConversationApiUrl(id), { method: 'DELETE' })
            .then(function () { loadConversations(); })
            .catch(function (err) {
                // If fetch fails, try SignalR invoke
                if (connection) connection.invoke('DeleteConversation', id, activePersona).catch(function () {});
            });
    }
}

function loadConversations(skipFallback) {
    fetch(getConversationApiUrl())
        .then(function (r) {
            if (!r || !r.ok) {
                throw new Error('Unable to load conversations');
            }
            return r.json();
        })
        .then(function (convs) {
            var list = document.getElementById('conversationList');
            if (!list) return;

            if (!Array.isArray(convs)) {
                convs = [];
            }

            var html = '';
            if (convs.length === 0) {
                html = '<div class="conv-loading">No conversations yet</div>';
            } else {
                convs.forEach(function (c) {
                    var active = c.id === currentConversationId ? ' active' : '';
                    html += '<div class="conv-item' + active + '" onclick="openConversationFromList(event, \'' + c.id + '\')">'
                        + '<span class="conv-title" ondblclick="renameConversation(event, \'' + c.id + '\', this)">' + escapeHtml(c.title || 'Chat') + '</span>'
                        + '<button class="conv-delete" onclick="event.stopPropagation(); deleteConversation(\'' + c.id + '\')">&times;</button>'
                        + '</div>';
                });
            }
            list.innerHTML = html;
        })
        .catch(function () {
            var list = document.getElementById('conversationList');
            if (list) {
                list.innerHTML = '<div class="conv-loading">Unable to load conversations</div>';
            }
        });
}

function clearPendingConversationOpen() {
    if (!conversationOpenTimer) return;
    clearTimeout(conversationOpenTimer);
    conversationOpenTimer = null;
}

function openConversationFromList(event, id) {
    if (event) event.stopPropagation();
    clearPendingConversationOpen();
    conversationOpenTimer = setTimeout(function () {
        conversationOpenTimer = null;
        loadConversation(id);
    }, 220);
}

function renameConversation(event, id, span) {
    if (event) {
        event.preventDefault();
        event.stopPropagation();
    }
    clearPendingConversationOpen();

    var current = span.textContent;
    var input = document.createElement('input');
    input.className = 'conv-rename-input';
    input.value = current;
    input.setAttribute('autofocus', '');
    span.parentNode.replaceChild(input, span);
    input.focus();
    input.select();

    var finished = false;
    function finish(keep) {
        if (finished) return;
        finished = true;
        var newTitle = input.value.trim() || current;
        if (keep && newTitle !== current) {
            connection.invoke('RenameConversation', id, newTitle, activePersona).catch(function () {});
        }
        var newSpan = document.createElement('span');
        newSpan.className = 'conv-title';
        newSpan.textContent = newTitle;
        newSpan.ondblclick = function (e) { renameConversation(e, id, newSpan); };
        if (input.parentNode) input.parentNode.replaceChild(newSpan, input);
    }

    input.addEventListener('click', function (e) { e.stopPropagation(); });
    input.addEventListener('dblclick', function (e) { e.stopPropagation(); });
    input.addEventListener('blur', function () { finish(true); });
    input.addEventListener('keydown', function (e) {
        if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
        if (e.key === 'Escape') { e.preventDefault(); finish(false); }
    });
}

function goToConversation(id) {
    loadConversation(id);
}

function clearMessages() { if (allMessagesDiv) allMessagesDiv.innerHTML = ''; }

function showWelcome() {
    clearMessages();
    var div = document.createElement('div');
    div.className = 'welcome';
    div.innerHTML = '<p>Hello, Raymond.</p><p class="welcome-sub">Send a message to start talking to Kai.</p>';
    allMessagesDiv.appendChild(div);
}

// --- memory sweep event rendering ---

function normalizeMemorySweepEvent(evt) {
    if (typeof evt === 'string') {
        return {
            id: activeMemorySweepId || 'memory-sweep-live',
            message: evt,
            report: evt
        };
    }
    evt = evt || {};
    if (!evt.id) evt.id = activeMemorySweepId || 'memory-sweep-live';
    return evt;
}

function getMemorySweepMeta(msg) {
    return msg.memorySweep || msg.MemorySweep || {};
}

function readMeta(meta, camelName, pascalName, fallback) {
    if (!meta) return fallback;
    if (meta[camelName] !== undefined) return meta[camelName];
    if (meta[pascalName] !== undefined) return meta[pascalName];
    return fallback;
}

function createMemorySweepElementFromMessage(msg, open) {
    var meta = getMemorySweepMeta(msg);
    return createMemorySweepElement({
        id: msg.id || msg.Id || ('memory-sweep-' + (readMeta(meta, 'compactionNumber', 'CompactionNumber', ''))),
        compactionNumber: readMeta(meta, 'compactionNumber', 'CompactionNumber', undefined),
        messagesSwept: readMeta(meta, 'messagesSwept', 'MessagesSwept', undefined),
        changed: readMeta(meta, 'changed', 'Changed', undefined),
        success: readMeta(meta, 'success', 'Success', undefined),
        operationCount: readMeta(meta, 'operationCount', 'OperationCount', undefined),
        writeCount: readMeta(meta, 'writeCount', 'WriteCount', undefined),
        deleteCount: readMeta(meta, 'deleteCount', 'DeleteCount', undefined),
        failedCount: readMeta(meta, 'failedCount', 'FailedCount', undefined),
        rawResponsePreview: readMeta(meta, 'rawResponsePreview', 'RawResponsePreview', ''),
        startedAt: readMeta(meta, 'startedAt', 'StartedAt', undefined),
        completedAt: readMeta(meta, 'completedAt', 'CompletedAt', getMessageTimestamp(msg)),
        report: getMessageContent(msg),
        message: readMeta(meta, 'status', 'Status', 'Memory sweep complete')
    }, open);
}

function createMemorySweepElement(data, open) {
    var details = document.createElement('details');
    details.className = 'compaction-event memory-sweep-event';
    details.dataset.memorySweepId = data.id || 'memory-sweep-live';
    details.open = !!open;

    var title = 'Memory Sweep';
    if (data.compactionNumber) title += ' before compaction #' + data.compactionNumber;

    details.innerHTML = '<summary>'
        + '<span class="compaction-event-title"></span>'
        + '<span class="compaction-event-counts"></span>'
        + '</summary>'
        + '<div class="compaction-event-body">'
        + '<div class="compaction-event-status"></div>'
        + '<div class="compaction-event-meta"></div>'
        + '<div class="compaction-event-grid memory-sweep-event-grid">'
        + '<section><h3>Operations</h3><pre class="memory-sweep-report-output"></pre></section>'
        + '</div>'
        + '</div>';

    details.querySelector('.compaction-event-title').textContent = title;
    setMemorySweepElementData(details, data);
    return details;
}

function findMemorySweepElement(id) {
    if (!allMessagesDiv) return null;
    return allMessagesDiv.querySelector('.memory-sweep-event[data-memory-sweep-id="' + cssEscapeValue(id || 'memory-sweep-live') + '"]');
}

function ensureLiveMemorySweepElement(data) {
    var id = data.id || activeMemorySweepId || 'memory-sweep-live';
    var details = findMemorySweepElement(id);
    if (details) return details;

    data.id = id;
    details = createMemorySweepElement(data, true);
    allMessagesDiv.appendChild(details);
    forceScrollBottom();
    return details;
}

function renderLiveMemorySweepEvent(data) {
    var details = ensureLiveMemorySweepElement(data);
    details.open = true;
    setMemorySweepElementData(details, data);
}

function updateLiveMemorySweepStatus(data) {
    var details = ensureLiveMemorySweepElement(data);
    if (data.message) {
        var status = details.querySelector('.compaction-event-status');
        if (status) status.textContent = data.message;
    }
    scrollBottom(isAtBottom());
}

function completeLiveMemorySweepEvent(data) {
    var details = ensureLiveMemorySweepElement(data);
    details.open = true;
    setMemorySweepElementData(details, data);
    scrollBottom(isAtBottom());
}

function setMemorySweepElementData(details, data) {
    var counts = details.querySelector('.compaction-event-counts');
    if (counts) {
        var parts = [];
        if (data.messagesSwept !== undefined) parts.push(formatCompactNumber(data.messagesSwept) + ' swept');
        if (data.operationCount !== undefined) parts.push(formatCompactNumber(data.operationCount) + ' ops');
        if (data.failedCount !== undefined && Number(data.failedCount) > 0) parts.push(formatCompactNumber(data.failedCount) + ' failed');
        if (data.changed !== undefined) parts.push(data.changed ? 'changed' : 'noop');
        counts.textContent = parts.join(' · ');
    }

    var status = details.querySelector('.compaction-event-status');
    if (status) status.textContent = data.message || 'Memory sweep event';

    var meta = details.querySelector('.compaction-event-meta');
    if (meta) {
        var metaParts = [];
        if (data.writeCount !== undefined) metaParts.push('Writes: ' + formatCompactNumber(data.writeCount));
        if (data.deleteCount !== undefined) metaParts.push('Removals: ' + formatCompactNumber(data.deleteCount));
        if (data.failedCount !== undefined) metaParts.push('Failures: ' + formatCompactNumber(data.failedCount));
        if (data.success !== undefined) metaParts.push('Success: ' + (data.success ? 'yes' : 'no'));
        meta.textContent = metaParts.join(' | ');
    }

    var report = details.querySelector('.memory-sweep-report-output');
    if (report) report.textContent = data.report || data.message || '';
}

// --- compaction event rendering ---

function normalizeCompactionEvent(evt) {
    if (typeof evt === 'string') {
        return {
            id: activeCompactionId || 'compaction-live',
            message: evt,
            text: evt
        };
    }
    evt = evt || {};
    if (!evt.id) evt.id = activeCompactionId || 'compaction-live';
    return evt;
}

function createCompactionElementFromMessage(msg, open) {
    var meta = msg.compaction || {};
    return createCompactionElement({
        id: msg.id || ('compaction-' + (meta.compactionNumber || '')),
        compactionNumber: meta.compactionNumber,
        archivePath: meta.archivePath,
        previousArchivePath: meta.previousArchivePath,
        messagesToCompact: meta.messagesToCompact,
        messagesToKeep: meta.messagesToKeep,
        chunkCount: meta.chunkCount,
        estimatedApiTokens: meta.estimatedApiTokens,
        estimatedPostCompactionApiTokens: meta.estimatedPostCompactionApiTokens,
        startedAt: meta.startedAt,
        completedAt: meta.completedAt || msg.timestamp,
        summary: msg.content || '',
        thinking: msg.thinking || '',
        message: 'Compaction complete'
    }, open);
}

function createCompactionElement(data, open) {
    var details = document.createElement('details');
    details.className = 'compaction-event';
    details.dataset.compactionId = data.id || 'compaction-live';
    details.open = !!open;

    var title = 'Compaction';
    if (data.compactionNumber) title += ' #' + data.compactionNumber;

    details.innerHTML = '<summary>'
        + '<span class="compaction-event-title"></span>'
        + '<span class="compaction-event-counts"></span>'
        + '</summary>'
        + '<div class="compaction-event-body">'
        + '<div class="compaction-event-status"></div>'
        + '<div class="compaction-event-meta"></div>'
        + '<div class="compaction-event-grid">'
        + '<section><h3>Thinking</h3><pre class="compaction-thinking-output"></pre></section>'
        + '<section><h3>Summary</h3><pre class="compaction-summary-output"></pre></section>'
        + '</div>'
        + '</div>';

    details.querySelector('.compaction-event-title').textContent = title;
    setCompactionElementData(details, data);
    return details;
}

function findCompactionElement(id) {
    if (!allMessagesDiv) return null;
    return allMessagesDiv.querySelector('.compaction-event[data-compaction-id="' + cssEscapeValue(id || 'compaction-live') + '"]');
}

function ensureLiveCompactionElement(data) {
    var id = data.id || activeCompactionId || 'compaction-live';
    var details = findCompactionElement(id);
    if (details) return details;

    data.id = id;
    details = createCompactionElement(data, true);
    allMessagesDiv.appendChild(details);
    forceScrollBottom();
    return details;
}

function renderLiveCompactionEvent(data) {
    var details = ensureLiveCompactionElement(data);
    details.open = true;
    setCompactionElementData(details, data);
}

function updateLiveCompactionStatus(data) {
    var details = ensureLiveCompactionElement(data);
    if (data.message) {
        var status = details.querySelector('.compaction-event-status');
        if (status) status.textContent = data.message;
    }
    scrollBottom(isAtBottom());
}

function appendLiveCompactionText(data, target) {
    var details = ensureLiveCompactionElement(data);
    var selector = target === 'thinking' ? '.compaction-thinking-output' : '.compaction-summary-output';
    var output = details.querySelector(selector);
    if (output && data.text) output.textContent += data.text;
    if (data.message) updateLiveCompactionStatus(data);
    scrollBottom(isAtBottom());
}

function completeLiveCompactionEvent(data) {
    var details = ensureLiveCompactionElement(data);
    details.open = true;
    setCompactionElementData(details, data);
    if (data.thinking !== undefined) {
        details.querySelector('.compaction-thinking-output').textContent = data.thinking || '';
    }
    if (data.summary !== undefined) {
        details.querySelector('.compaction-summary-output').textContent = data.summary || '';
    }
    scrollBottom(isAtBottom());
}

function setCompactionElementData(details, data) {
    var counts = details.querySelector('.compaction-event-counts');
    if (counts) {
        var parts = [];
        if (data.messagesToCompact !== undefined) parts.push(formatCompactNumber(data.messagesToCompact) + ' archived');
        if (data.messagesToKeep !== undefined) parts.push(formatCompactNumber(data.messagesToKeep) + ' kept');
        if (data.chunkCount !== undefined) parts.push(formatCompactNumber(data.chunkCount) + ' chunks');
        counts.textContent = parts.join(' · ');
    }

    var status = details.querySelector('.compaction-event-status');
    if (status) status.textContent = data.message || 'Compaction event';

    var meta = details.querySelector('.compaction-event-meta');
    if (meta) {
        var metaParts = [];
        if (data.estimatedApiTokens !== undefined) metaParts.push('Before: ' + formatCompactNumber(data.estimatedApiTokens));
        if (data.estimatedPostCompactionApiTokens !== undefined) metaParts.push('After: ' + formatCompactNumber(data.estimatedPostCompactionApiTokens));
        if (data.archivePath) metaParts.push('Archive: ' + data.archivePath);
        if (data.previousArchivePath) metaParts.push('Previous: ' + data.previousArchivePath);
        meta.textContent = metaParts.join(' | ');
    }

    if (data.thinking !== undefined) {
        details.querySelector('.compaction-thinking-output').textContent = data.thinking || '';
    }
    if (data.summary !== undefined) {
        details.querySelector('.compaction-summary-output').textContent = data.summary || '';
    }
}

function formatCompactNumber(value) {
    if (value === null || value === undefined || value === '') return '-';
    return Number(value).toLocaleString();
}

function cssEscapeValue(value) {
    if (window.CSS && CSS.escape) return CSS.escape(value);
    return String(value).replace(/["\\]/g, '\\$&');
}

function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); }
function closeSidebar() { document.getElementById('sidebar').classList.remove('open'); }

function escapeHtml(text) {
    var d = document.createElement('div');
    d.textContent = text;
    return d.innerHTML;
}

// --- Browser notifications ---

function setupKaiNotifications() {
    updateAllKaiNotificationButtons();

    if (isKaiNotificationsEnabled()) {
        ensureKaiNotificationRegistration().then(updateAllKaiNotificationButtons).catch(function () {
            updateAllKaiNotificationButtons();
        });
    }
}

function isKaiLoopbackHost(hostname) {
    var host = String(hostname || '').toLowerCase();
    return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
}

function getKaiNotificationSupport() {
    if (!('Notification' in window)) {
        return {
            supported: false,
            reason: 'Notifications are not available in this browser.',
            className: 'blocked'
        };
    }

    if (!window.isSecureContext) {
        var reason = isKaiLoopbackHost(location.hostname)
            ? 'Firefox requires localhost or HTTPS before it can request notifications.'
            : 'Firefox requires HTTPS for LAN notifications. Use the HTTPS LAN URL, then check again.';
        return {
            supported: false,
            reason: reason,
            className: 'blocked'
        };
    }

    if (!('serviceWorker' in navigator)) {
        return {
            supported: true,
            reason: 'Permission can be requested, but background delivery may be limited because service workers are unavailable.',
            className: 'waiting'
        };
    }

    return {
        supported: true,
        reason: 'Ready to request notification permission.',
        className: 'waiting'
    };
}

function isKaiNotificationSupported() {
    return getKaiNotificationSupport().supported;
}

function isKaiNotificationsEnabled() {
    return isKaiNotificationSupported() && Notification.permission === 'granted';
}

function enableKaiNotifications() {
    var support = getKaiNotificationSupport();
    if (!support.supported) {
        showToast(support.reason, 5000);
        updateAllKaiNotificationButtons();
        return Promise.resolve(false);
    }

    return Notification.requestPermission().then(function (permission) {
        if (permission !== 'granted') {
            try { localStorage.removeItem(KAI_NOTIFICATIONS_KEY); } catch (e) {}
            showToast('Notifications not enabled', 3000);
            updateAllKaiNotificationButtons();
            return false;
        }

        try { localStorage.setItem(KAI_NOTIFICATIONS_KEY, '1'); } catch (e) {}
        return ensureKaiNotificationRegistration().then(function () {
            showToast('KaiChat notifications enabled', 2500);
            updateAllKaiNotificationButtons();
            return true;
        });
    }).catch(function (error) {
        showToast((error && error.message) || 'Could not enable notifications', 4000);
        updateAllKaiNotificationButtons();
        return false;
    });
}

function ensureKaiNotificationRegistration() {
    if (!('serviceWorker' in navigator)) {
        return Promise.resolve(null);
    }

    if (kaiNotificationRegistration) {
        return Promise.resolve(kaiNotificationRegistration);
    }

    return navigator.serviceWorker.register('/kai-notifications-sw.js').then(function (registration) {
        kaiNotificationRegistration = registration;
        return registration;
    });
}

function updateAllKaiNotificationButtons() {
    updateKaiNotificationButton(document.getElementById('notificationEnableBtn'));
    updateKaiNotificationButton(document.getElementById('timerNotificationBtn'));
    updateKaiNotificationButton(document.getElementById('settingsNotificationBtn'));
    updateKaiNotificationStatusText();
}

function updateKaiNotificationButton(button) {
    if (!button) return;

    var support = getKaiNotificationSupport();
    button.title = support.reason;

    if (!support.supported) {
        button.hidden = button.id !== 'settingsNotificationBtn';
        button.textContent = 'Notifications unavailable';
        button.disabled = true;
        return;
    }

    button.hidden = false;
    if (Notification.permission === 'granted' && isKaiNotificationsEnabled()) {
        button.textContent = 'Notifications on';
        button.disabled = true;
        button.title = 'Notifications are allowed for this browser.';
    } else if (Notification.permission === 'denied') {
        button.textContent = 'Notifications blocked';
        button.disabled = false;
        button.title = 'Firefox has blocked notifications for this site. Change the site permission, then check again.';
    } else {
        button.textContent = 'Enable notifications';
        button.disabled = false;
    }
}

function updateKaiNotificationStatusText() {
    var status = document.getElementById('settingsNotificationStatus');
    if (!status) return;

    var support = getKaiNotificationSupport();
    if (!support.supported) {
        status.textContent = support.reason;
        status.className = 'settings-notification-status ' + support.className;
        return;
    }

    if (Notification.permission === 'granted' && isKaiNotificationsEnabled()) {
        status.textContent = 'Allowed';
        status.className = 'settings-notification-status allowed';
    } else if (Notification.permission === 'denied') {
        status.textContent = 'Blocked by browser permission. Change Firefox site permissions, then check again.';
        status.className = 'settings-notification-status blocked';
    } else {
        status.textContent = support.reason;
        status.className = 'settings-notification-status waiting';
    }
}

window.updateKaiNotificationButton = updateKaiNotificationButton;
window.enableKaiNotifications = enableKaiNotifications;
window.getKaiNotificationSupport = getKaiNotificationSupport;

function notifyKaiMessage(conversationId, msg, source) {
    if (!isKaiNotificationsEnabled()) return;
    shouldSuppressKaiNotification().then(function (suppress) {
        if (suppress) return;
        showKaiNotification(conversationId, msg, source);
    });
}

function showKaiNotification(conversationId, msg, source) {
    if (!isKaiNotificationsEnabled()) return;
    if (isAnyKaiClientForeground()) return;

    var content = sanitizeAssistantDisplayText(getMessageContent(msg)).replace(/\s+/g, ' ').trim();
    if (!content) return;
    var body = content.length > 180 ? content.slice(0, 177) + '...' : content;
    var messageId = (msg && (msg.id || msg.Id)) || generateId();
    var url = getConversationUrl(conversationId || currentConversationId || '');
    var title = source === 'kaitimer_milestone' ? 'KaiTimer milestone' : 'Kai';
    var options = {
        body: body,
        tag: 'kaichat-' + (conversationId || 'kai') + '-' + messageId,
        data: { url: url },
        requireInteraction: source === 'kaitimer_milestone'
    };

    ensureKaiNotificationRegistration().then(function (registration) {
        if (isAnyKaiClientForeground()) return;
        if (registration && registration.showNotification) {
            return registration.showNotification(title, options);
        }

        if (isAnyKaiClientForeground()) return;
        var notification = new Notification(title, options);
        notification.onclick = function () {
            window.focus();
            if (conversationId) loadConversation(conversationId);
            notification.close();
        };
    }).catch(function () {
        if (isAnyKaiClientForeground()) return;
        try {
            var notification = new Notification(title, options);
            notification.onclick = function () {
                window.focus();
                if (conversationId) loadConversation(conversationId);
                notification.close();
            };
        } catch (e) {}
    });
}

// --- Toast notifications ---

function showToast(message, durationMs) {
    var existing = document.querySelector('.toast');
    if (existing) existing.remove();
    var toast = document.createElement('div');
    toast.className = 'toast';
    toast.textContent = message;
    document.body.appendChild(toast);
    if (durationMs > 0) {
        setTimeout(function () {
            toast.classList.add('toast-hide');
            setTimeout(function () { toast.remove(); }, 300);
        }, durationMs);
    }
    // Click to dismiss
    toast.addEventListener('click', function () { toast.remove(); });
}

// --- SVG rendering helpers ---

function decodeHtmlEntities(text) {
    var textarea = document.createElement('textarea');
    textarea.innerHTML = String(text || '');
    return textarea.value;
}

function isSvgCodeBlock(lang, code) {
    var language = String(lang || '').trim().toLowerCase();
    var raw = String(code || '').trim();
    return language === 'svg' || (!language && /^<svg[\s>]/i.test(raw));
}

function sanitizeSvgSource(source) {
    var raw = String(source || '').trim();
    if (!raw) return null;

    var doc = new DOMParser().parseFromString(raw, 'image/svg+xml');
    if (doc.querySelector('parsererror')) return null;

    var svg = doc.documentElement;
    if (!svg || svg.nodeName.toLowerCase() !== 'svg') return null;

    svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');

    var blocked = doc.querySelectorAll('script, foreignObject, iframe, object, embed, link, meta, base');
    blocked.forEach(function (node) { node.remove(); });

    doc.querySelectorAll('*').forEach(function (node) {
        Array.prototype.slice.call(node.attributes).forEach(function (attr) {
            var name = attr.name.toLowerCase();
            var value = String(attr.value || '').trim();
            var valueLower = value.toLowerCase();

            if (name.indexOf('on') === 0 || valueLower.indexOf('javascript:') >= 0) {
                node.removeAttribute(attr.name);
                return;
            }

            if ((name === 'href' || name === 'xlink:href')
                && /^(https?:|file:|data:text\/html)/i.test(value)) {
                node.removeAttribute(attr.name);
            }
        });
    });

    return new XMLSerializer().serializeToString(svg);
}

function svgToDataUrl(svgSource) {
    return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgSource);
}

function registerSvgSource(svgSource) {
    var id = 'svg-' + (++svgRenderCounter).toString(36);
    svgRenderSources[id] = svgSource;
    return id;
}

function renderSvgCodeBlock(svgSource) {
    var sanitized = sanitizeSvgSource(svgSource);
    if (!sanitized) return null;

    var id = registerSvgSource(sanitized);
    var sourceId = 'svg-source-' + id;
    var dataUrl = svgToDataUrl(sanitized);

    return '<figure class="svg-render-block" data-svg-id="' + id + '">'
        + '<div class="svg-render-toolbar">'
        + '<span class="svg-render-label">SVG</span>'
        + '<button type="button" class="svg-render-btn" data-svg-source-button-id="' + id + '" onclick="toggleSvgSource(\'' + id + '\')">Source</button>'
        + '<button type="button" class="svg-render-btn" onclick="openSvgViewer(\'' + id + '\')">Full size</button>'
        + '<button type="button" class="svg-render-btn" onclick="downloadSvgAsPng(\'' + id + '\')">PNG</button>'
        + '</div>'
        + '<button type="button" class="svg-preview-shell" onclick="openSvgViewer(\'' + id + '\')" title="Open full size SVG preview">'
        + '<img class="svg-preview-img" alt="SVG preview" src="' + escapeHtml(dataUrl) + '">'
        + '</button>'
        + '<pre class="svg-source-panel" id="' + sourceId + '" hidden><code>' + escapeHtml(sanitized) + '</code></pre>'
        + '</figure>';
}

function toggleSvgSource(id) {
    var panel = document.getElementById('svg-source-' + id);
    if (!panel) return;

    panel.hidden = !panel.hidden;
    var button = document.querySelector('[data-svg-source-button-id="' + cssEscapeValue(id) + '"]');
    if (button) button.textContent = panel.hidden ? 'Source' : 'Hide source';
}

function ensureSvgLightbox() {
    var existing = document.getElementById('svgLightbox');
    if (existing) return existing;

    var lightbox = document.createElement('div');
    lightbox.id = 'svgLightbox';
    lightbox.className = 'svg-lightbox';
    lightbox.hidden = true;

    var close = document.createElement('button');
    close.type = 'button';
    close.className = 'svg-lightbox-close';
    close.textContent = 'Close';
    close.addEventListener('click', closeSvgViewer);

    var img = document.createElement('img');
    img.className = 'svg-lightbox-img';
    img.alt = 'Full size SVG preview';

    lightbox.appendChild(close);
    lightbox.appendChild(img);
    lightbox.addEventListener('click', function (event) {
        if (event.target === lightbox) closeSvgViewer();
    });

    document.body.appendChild(lightbox);
    return lightbox;
}

function openSvgViewer(id) {
    var source = svgRenderSources[id];
    if (!source) {
        showToast('SVG source not found', 2500);
        return;
    }

    var lightbox = ensureSvgLightbox();
    var img = lightbox.querySelector('.svg-lightbox-img');
    img.src = svgToDataUrl(source);
    lightbox.hidden = false;
    document.body.classList.add('svg-lightbox-open');

    if (!svgLightboxKeyHandler) {
        svgLightboxKeyHandler = function (event) {
            if (event.key === 'Escape') closeSvgViewer();
        };
        document.addEventListener('keydown', svgLightboxKeyHandler);
    }
}

function closeSvgViewer() {
    var lightbox = document.getElementById('svgLightbox');
    if (lightbox) {
        lightbox.hidden = true;
        var img = lightbox.querySelector('.svg-lightbox-img');
        if (img) img.removeAttribute('src');
    }

    document.body.classList.remove('svg-lightbox-open');
    if (svgLightboxKeyHandler) {
        document.removeEventListener('keydown', svgLightboxKeyHandler);
        svgLightboxKeyHandler = null;
    }
}

function getSvgRasterSize(svgSource) {
    var fallback = { width: 1200, height: 800 };
    var doc = new DOMParser().parseFromString(svgSource, 'image/svg+xml');
    var svg = doc.documentElement;
    if (!svg || svg.nodeName.toLowerCase() !== 'svg') return fallback;

    var width = parseSvgLength(svg.getAttribute('width'));
    var height = parseSvgLength(svg.getAttribute('height'));
    var viewBox = parseSvgViewBox(svg.getAttribute('viewBox'));

    if ((!width || !height) && viewBox) {
        width = width || viewBox.width;
        height = height || viewBox.height;
    }

    if (!width || !height) return fallback;
    return {
        width: Math.max(1, Math.round(width)),
        height: Math.max(1, Math.round(height))
    };
}

function parseSvgLength(value) {
    if (!value) return 0;
    var match = String(value).trim().match(/^([0-9]+(?:\.[0-9]+)?)/);
    return match ? parseFloat(match[1]) : 0;
}

function parseSvgViewBox(value) {
    if (!value) return null;
    var parts = String(value).trim().split(/[\s,]+/).map(parseFloat);
    if (parts.length !== 4 || parts.some(function (part) { return !isFinite(part); })) return null;
    return { width: Math.abs(parts[2]), height: Math.abs(parts[3]) };
}

function downloadSvgAsPng(id) {
    var source = svgRenderSources[id];
    if (!source) {
        showToast('SVG source not found', 2500);
        return;
    }

    var size = getSvgRasterSize(source);
    var maxSide = 4096;
    var scale = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
    if (Math.max(size.width, size.height) * scale > maxSide) {
        scale = maxSide / Math.max(size.width, size.height);
    }

    var canvas = document.createElement('canvas');
    canvas.width = Math.max(1, Math.round(size.width * scale));
    canvas.height = Math.max(1, Math.round(size.height * scale));

    var img = new Image();
    var objectUrl = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml;charset=utf-8' }));
    img.onload = function () {
        try {
            var ctx = canvas.getContext('2d');
            ctx.setTransform(scale, 0, 0, scale, 0, 0);
            ctx.drawImage(img, 0, 0, size.width, size.height);

            var link = document.createElement('a');
            link.download = 'kaichat-svg-' + id + '.png';
            link.href = canvas.toDataURL('image/png');
            document.body.appendChild(link);
            link.click();
            link.remove();
        } catch (error) {
            showToast('Could not export SVG as PNG', 3000);
        } finally {
            URL.revokeObjectURL(objectUrl);
        }
    };
    img.onerror = function () {
        URL.revokeObjectURL(objectUrl);
        showToast('Could not load SVG for PNG export', 3000);
    };
    img.src = objectUrl;
}

// --- Markdown Renderer ---

function renderLastMessageMarkdown() {
    // Render every assistant message that hasn't been rendered yet.
    // During tool-call follow-ups the DOM may have multiple assistant messages,
    // and only the last one would be picked up by a "lastElementChild" approach.
    var rendered = allMessagesDiv.querySelectorAll('.message.assistant[data-rendered]');
    var all = allMessagesDiv.querySelectorAll('.message.assistant');
    for (var i = rendered.length; i < all.length; i++) {
        var msg = all[i];
        if (msg.dataset.rendered) continue;
        // data-translated has highest priority (completed or streaming translation).
        // data-raw is the pre-translation raw text fallback.
        // textContent is the last resort.
        var raw = sanitizeAssistantDisplayText(msg.dataset.translated || msg.dataset.raw || msg.textContent);
        var shouldAutoScroll = isAtBottom();
        msg.innerHTML = renderMarkdown(raw);
        msg.dataset.rendered = '1';
        scrollBottom(shouldAutoScroll);
    }
}

function renderTranslationProgress(translated, original, consumedSymbols) {
    var tail = sliceSymbols(original || '', consumedSymbols || 0);
    var marker = '\x00TRANSLATION_TAIL\x00';
    var html = renderMarkdown((translated || '') + marker);
    var tailHtml = tail
        ? '<span class="translation-original-tail">' + escapeHtml(tail) + '</span>'
        : '';
    return html.replace(marker, tailHtml);
}

function countSymbols(text) {
    return Array.from(text || '').length;
}

function sliceSymbols(text, start) {
    return Array.from(text || '').slice(start || 0).join('');
}

function renderBlockquotes(escaped) {
    return escaped.replace(/(^|\n)((?:&gt;[^\n]*(?:\n|$))+)/g, function (_match, prefix, block) {
        var paragraphs = [];
        var current = [];
        var lines = block.split('\n');

        lines.forEach(function (line) {
            line = line.replace(/\r$/, '');
            if (!line) return;

            var content = line.replace(/^&gt;[ \t]?/, '');
            if (!content.trim()) {
                if (current.length) {
                    paragraphs.push(current.join('<br>'));
                    current = [];
                }
                return;
            }

            current.push(content);
        });

        if (current.length) paragraphs.push(current.join('<br>'));
        if (!paragraphs.length) return prefix;

        var quote = '<blockquote>' + paragraphs.map(function (paragraph) {
            return '<p>' + paragraph + '</p>';
        }).join('') + '</blockquote>';

        return prefix + quote + (/\r?\n$/.test(block) ? '\n' : '');
    });
}

function renderMarkdown(text) {
    // Tool call markers — extract and replace with placeholders before HTML escaping
    // so they survive escaping and code-block processing intact.
    // Format: [TOOL_CALL: GET|POST /path, {json body...}]
    var toolCalls = [];
    text = text.replace(/\[TOOL_CALL:\s*(GET|POST|PUT|PATCH|DELETE)\s+(\/[^,]+?),\s*(\{[\s\S]*?\})\s*\]/g, function(m) {
        toolCalls.push(m);
        return '\x00TC' + (toolCalls.length - 1) + '\x00';
    });

    // Escape HTML first to prevent XSS
    var escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

    // Restore tool call placeholders as formatted action-line blocks
    for (var i = 0; i < toolCalls.length; i++) {
        var ph = '\x00TC' + i + '\x00';
        var raw = toolCalls[i];
        var mm = raw.match(/\[TOOL_CALL:\s*(GET|POST|PUT|PATCH|DELETE)\s+(\/[^,]+?),\s*(\{[\s\S]*?\})\s*\]/);
        var label = '';
        if (mm) {
            var path = mm[2];
            try {
                var parsed = JSON.parse(mm[3]);
                if (path.indexOf('/api/files/read') >= 0 && parsed.path) {
                    var fn = parsed.path;
                    var ls = Math.max(fn.lastIndexOf('/'), fn.lastIndexOf('\\'));
                    if (ls >= 0) fn = fn.substring(ls + 1);
                    label = 'reading ' + fn;
                } else if (path.indexOf('/api/search') >= 0 && parsed.q) {
                    label = 'searching: ' + parsed.q;
                } else if (path.indexOf('/api/memory') >= 0) {
                    label = 'updating memory';
                } else if (path.indexOf('/api/bible') >= 0) {
                    label = 'updating bible';
                } else if (path.indexOf('/api/git') >= 0) {
                    label = 'committing changes';
                } else if (path.indexOf('/api/instructions') >= 0) {
                    label = 'rewriting instructions';
                }
            } catch (e) {}
            if (!label) label = 'tool: ' + path.split('/').pop();
        }
        escaped = escaped.replace(ph, '<div class="msg-tool-call">~ ' + label + ' ~</div>');
    }

    // Code blocks (fenced) — must be processed first
    escaped = escaped.replace(/```([\w-]*)\n([\s\S]*?)```/g, function(m, lang, code) {
        var rawCode = decodeHtmlEntities(code);
        if (isSvgCodeBlock(lang, rawCode)) {
            var svgBlock = renderSvgCodeBlock(rawCode);
            if (svgBlock) return svgBlock;
        }

        var safeLang = String(lang || '').replace(/[^\w-]/g, '');
        return '<pre><code class="lang-' + safeLang + '">' + code.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</code></pre>';
    });
    // Inline code
    escaped = escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
    // Headers
    escaped = escaped.replace(/^### (.+)$/gm, '<h4>$1</h4>');
    escaped = escaped.replace(/^## (.+)$/gm, '<h3>$1</h3>');
    escaped = escaped.replace(/^# (.+)$/gm, '<h2>$1</h2>');
    // Bold and italic
    escaped = escaped.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
    escaped = escaped.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
    escaped = escaped.replace(/\*(.+?)\*/g, '<em>$1</em>');
    escaped = escaped.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>');
    escaped = escaped.replace(/__(.+?)__/g, '<strong>$1</strong>');
    escaped = escaped.replace(/_(.+?)_/g, '<em>$1</em>');
    // Strikethrough
    escaped = escaped.replace(/~~(.+?)~~/g, '<del>$1</del>');
    // Horizontal rules
    escaped = escaped.replace(/^---$/gm, '<hr>');
    // Blockquotes
    escaped = renderBlockquotes(escaped);
    // Unordered lists
    escaped = escaped.replace(/^[\*\-] (.+)$/gm, '<li>$1</li>');
    escaped = escaped.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
    // Ordered lists
    escaped = escaped.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
    escaped = escaped.replace(/(<li>.*<\/li>\n?)+/g, function(m) {
        return m.indexOf('<ol>') === -1 ? '<ol>' + m + '</ol>' : m;
    });
    // Line breaks and paragraphs
    escaped = escaped.replace(/\n\n/g, '</p><p>');
    escaped = escaped.replace(/\n/g, '<br>');
    escaped = '<p>' + escaped + '</p>';
    escaped = escaped.replace(/<p><blockquote>/g, '<blockquote>');
    escaped = escaped.replace(/<\/blockquote><\/p>/g, '</blockquote>');
    escaped = escaped.replace(/<p><figure class="svg-render-block"/g, '<figure class="svg-render-block"');
    escaped = escaped.replace(/<\/figure><\/p>/g, '</figure>');
    // Clean empty paragraphs
    escaped = escaped.replace(/<p><\/p>/g, '');
    return escaped;
}
Offline