/**
 * SailWP AI Co-pilot — persistent rail (admin + front-end), v0.3.0.
 * Vanilla JS, no build step. Renders into #sailwp-copilot-rail.
 *
 * v0.3.0 adds tool-use: chat now flows through /chat-with-tools, the
 * server returns Anthropic content blocks (text + tool_use), and each
 * tool_use is rendered as an action-chip the user confirms before the
 * plugin executes the side-effect. WordPress keeps a revision on every
 * write so changes are reversible from the editor.
 */
(function () {
    'use strict';

    var config = window.SailWPCopilot;
    if (!config) return;
    var root = document.getElementById('sailwp-copilot-rail');
    if (!root) return;

    var STORAGE_KEY = 'sailwp-copilot-rail.collapsed';
    var HISTORY_KEY = 'sailwp-copilot-rail.history';
    var PREVIEW_KEY = 'sailwp-copilot-rail.lastPreview';
    var HISTORY_MAX = 40;
    var i18n = config.i18n || {};
    var suggestions = config.suggestions || [];

    /**
     * messages[] entries:
     *   { role: 'user'|'assistant'|'error', content: string | Array<Block> }
     * Block shapes:
     *   { type: 'text', text }
     *   { type: 'tool_use', id, name, input }
     *   { type: 'tool_result', tool_use_id, content, is_error? }
     *
     * UI metadata kept alongside the message in `ui` (not sent to API):
     *   ui.toolStatus = { [tool_use_id]: 'pending'|'running'|'done'|'cancelled'|'error' }
     *   ui.toolResults = { [tool_use_id]: { ok, message, view_url, edit_url, ... } }
     */
    var messages = [];
    var pending = false;
    // Heavy-action quota (image gen, video gen, music gen, page design).
    // null = unlimited (Pro / Founder); { heavy_used, heavy_limit, period_end } = Free.
    var quotaState = (config && config.quota) ? config.quota : null;

    // Brand icon — canonical SailWP PNG (sailwp-icon-new.png, identical to
    // sailwp.com header + feedback bubble). The v0.22.0 inline-SVG sail
    // didn't match the brand mark; restored 2026-05-17 per Joost.
    var iconSailwpSrc = ((config && config.pluginUrl) || '') + 'assets/img/sailwp-icon.png';
    var iconSailwp = (
        '<img class="sw-rail-brand-img" src="' +
        iconSailwpSrc.replace(/"/g, '&quot;') +
        '" alt="" aria-hidden="true" />'
    );
    // Chevron-down for "tuck the rail away" (softer than X)
    var iconClose = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
    // Circular-arrow refresh — universally read as "start over"
    var iconNew = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
    var iconMic = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M19 11a7 7 0 0 1-14 0"/><line x1="12" y1="18" x2="12" y2="22"/></svg>';
    // Paperclip for "attach a file"
    var iconAttach = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.99 8.86L9.41 17.43a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
    // Crosshair for "click an element on the page"
    var iconPick = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="3" x2="12" y2="7"/><line x1="12" y1="17" x2="12" y2="21"/><line x1="3" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="21" y2="12"/><circle cx="12" cy="12" r="2"/></svg>';
    // Paper-plane for send
    var iconSend = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
    // Clock with one tick — "history"
    var iconHistory = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';

    // === Voice input (browser-native SpeechRecognition) ===
    var SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition;
    var voiceSupported = !!SpeechRec;
    var recognition = null;
    var listening = false;
    var voiceBaseText = '';

    // === Pending file attachments ===
    // { name, kind: 'pdf'|'word'|'text', dataUrl?, text? }
    var pendingAttachments = [];
    var ATTACH_MAX = 3;
    var ATTACH_MAX_BYTES = 10 * 1024 * 1024; // 10 MB cap per file

    function escapeHtml(s) {
        return String(s == null ? '' : s)
            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    }

    function renderMarkdown(text) {
        var html = escapeHtml(text);
        html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
        html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
        html = html.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
        html = html.replace(/(^|[^\w*])\*([^*\n]+)\*(?!\w)/g, '$1<em>$2</em>');
        html = html.replace(/(^|[^\w_])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
        html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g,
            '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');

        var lines = html.split('\n');
        var blocks = [];
        var cur = { type: null, lines: [] };
        function flush() {
            if (cur.type && cur.lines.length) blocks.push(cur);
            cur = { type: null, lines: [] };
        }
        lines.forEach(function (line) {
            var listItem = /^\s*(?:[-*]|\d+\.)\s+(.*)$/.exec(line);
            if (listItem) {
                if (cur.type !== 'list') flush();
                cur.type = 'list';
                cur.lines.push(listItem[1]);
            } else if (line.trim() === '') {
                flush();
            } else {
                if (cur.type !== 'p') flush();
                cur.type = 'p';
                cur.lines.push(line);
            }
        });
        flush();
        return blocks.map(function (b) {
            if (b.type === 'list') {
                return '<ul>' + b.lines.map(function (l) { return '<li>' + l + '</li>'; }).join('') + '</ul>';
            }
            return '<p>' + b.lines.join('<br>') + '</p>';
        }).join('');
    }

    function adminBarHeight() {
        var bar = document.getElementById('wpadminbar');
        return bar ? bar.offsetHeight : 0;
    }
    function applyAdminBarOffset() {
        var h = adminBarHeight();
        if (h === 0) {
            root.setAttribute('data-no-adminbar', 'true');
        } else {
            root.removeAttribute('data-no-adminbar');
            root.style.setProperty('top', h + 'px');
        }
    }

    // Map style words to emoji prefixes; falls back to a small dot.
    var STYLE_EMOJI = {
        calm: '🌿', peaceful: '🌿', serene: '🌿', wellness: '🌿',
        warm: '☀️', welcoming: '☀️', cozy: '🤍', gentle: '🤍', soft: '🤍',
        spacious: '✨', airy: '✨', breezy: '🌊', flowing: '🌊',
        minimal: '◯', clean: '◯', modern: '💎',
        bold: '⚡', strong: '⚡', dynamic: '⚡',
        editorial: '📰', classic: '📜',
        playful: '🌸', fun: '🌸', vibrant: '🌈',
        professional: '💼', refined: '💼',
        spiritual: '🪷', sacred: '🪷',
        elegant: '🕊️', luxurious: '🕊️'
    };
    function styleTagsHtml(hint) {
        if (!hint) return '';
        // Long style_hints (whole paragraphs with sentences and periods) used
        // to explode into 30+ pills, drowning the chip. Cap the pill count and
        // keep each pill compact — anything past 4 lives in the prompt itself,
        // not on screen. Designed with the Priscilla filter in mind: low
        // cognitive load, scannable, never a wall of text.
        var raw = String(hint).split(/[,;.\n]+/).map(function (s) { return s.trim(); }).filter(Boolean);
        if (!raw.length) return '';
        var MAX_TAGS = 4;
        var MAX_TAG_CHARS = 26;
        var seen = {};
        var tags = [];
        for (var i = 0; i < raw.length && tags.length < MAX_TAGS; i++) {
            var t = raw[i];
            if (t.length > MAX_TAG_CHARS) continue;
            var k = t.toLowerCase();
            if (seen[k]) continue;
            seen[k] = true;
            tags.push(t);
        }
        if (!tags.length) return '';
        return '<div class="sw-rail-chip-tags">' + tags.map(function (t) {
            var key = t.toLowerCase().split(/\s+/)[0];
            var emoji = STYLE_EMOJI[key] || '·';
            return '<span class="sw-rail-chip-tag">' + emoji + ' ' + escapeHtml(t) + '</span>';
        }).join('') + '</div>';
    }

    /**
     * Trim a string at the last word boundary before `max` chars and
     * append an ellipsis if anything was cut. Avoids the "...futuristic
     * colo" mid-word amputation Joost flagged on 2026-05-17.
     */
    function truncateAtWord(s, max) {
        s = String(s == null ? '' : s).trim();
        if (s.length <= max) return s;
        var cut = s.slice(0, max);
        var sp = cut.lastIndexOf(' ');
        if (sp > Math.floor(max * 0.6)) cut = cut.slice(0, sp);
        return cut.replace(/[\s,;:.\-—–]+$/, '') + '…';
    }

    /**
     * Build a chip body: a one-line label and (for design tools) a row of
     * style-pill tags with relevant emojis. No em-dashes, no parens.
     */
    function chipLabelHtml(toolUse) {
        var name = (toolUse && toolUse.name) || '';
        var input = (toolUse && toolUse.input) || {};
        var page = input.page || 'this page';
        var label;
        switch (name) {
            case 'redesign_page':
                label = 'Clean up the “' + page + '” page';
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>' + styleTagsHtml(input.style_hint);
            case 'design_page_preview':
                label = 'Design a preview of the “' + page + '” page';
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>' + styleTagsHtml(input.style_hint);
            case 'iterate_page_design':
                label = 'Tweak the preview' + (input.instruction ? ': ' + input.instruction : '');
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'apply_page_design':
                label = 'Replace the live page with the approved design';
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div><div class="sw-rail-chip-subnote">slug, internal links and SEO stay intact</div>';
            case 'convert_testimonials_to_carousel':
                label = 'Turn testimonials on “' + page + '” into a carousel';
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'fix_duplicate_titles':
                label = 'Remove duplicate titles on “' + page + '”';
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'normalize_headings':
                label = 'Tidy the heading hierarchy on “' + page + '”';
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'wire_contact_form_to_email':
                label = 'Send contact form submissions to ' + (input.email || '<email>');
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'insert_image_block':
                var pos = input.position ? ' (' + input.position + ')' : '';
                label = 'Add the photo to “' + page + '”' + pos;
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'generate_image':
                label = 'Generate image: ' + truncateAtWord(input.prompt || '', 70);
                return '<div class="sw-rail-chip-label" title="' + escapeHtml(input.prompt || '') + '">' + escapeHtml(label) + '</div>';
            case 'generate_video':
                label = 'Generate video: ' + truncateAtWord(input.prompt || '', 60);
                return '<div class="sw-rail-chip-label" title="' + escapeHtml(input.prompt || '') + '">' + escapeHtml(label) + '</div><div class="sw-rail-chip-subnote">~8 seconds, takes 30 to 90s</div>';
            case 'generate_music':
                label = 'Generate music: ' + truncateAtWord(input.prompt || '', 60);
                return '<div class="sw-rail-chip-label" title="' + escapeHtml(input.prompt || '') + '">' + escapeHtml(label) + '</div><div class="sw-rail-chip-subnote">~30 seconds</div>';
            case 'swap_or_add_image':
                label = (input.action === 'add' ? 'Add an image on ' : 'Swap an image on ') + '“' + page + '”';
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'update_block_attrs':
                // Most common: color/link/alignment tweak on a single block.
                // Surface WHICH attribute when we can — "Change the colour",
                // "Update the link", etc. — instead of the tool name.
                label = blockAttrsLabel(input);
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'update_page_text':
                label = 'Rewrite the text on “' + page + '”';
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'swap_image_in_block':
                label = 'Swap the image' + (page && page !== 'this page' ? ' on “' + page + '”' : '');
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'delete_block':
                label = 'Remove that block';
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'insert_block':
                label = 'Add a new block' + (page && page !== 'this page' ? ' on “' + page + '”' : '');
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'restore_page_version':
                label = 'Restore an earlier version of “' + page + '”';
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            case 'navigate_to':
                label = 'Open ' + (input.target_label || input.target_url || 'that page');
                return '<div class="sw-rail-chip-label">' + escapeHtml(label) + '</div>';
            default:
                // Friendly fallback — strip underscores so an unmapped tool
                // reads as "update something" rather than "update_something".
                var human = String(name).replace(/_/g, ' ');
                return '<div class="sw-rail-chip-label">' + escapeHtml(human ? human.charAt(0).toUpperCase() + human.slice(1) : 'Make a change') + '</div>';
        }
    }

    /**
     * Friendly label for update_block_attrs. The chip wants to read like
     * "Change the background colour" or "Update the link", not "Run
     * update_block_attrs". We sniff the attrs/inner_text and pick the most
     * relevant phrase. Falls back to a generic "Change that block".
     */
    function blockAttrsLabel(input) {
        var attrs = (input && input.attrs) || {};
        var hasText = input && typeof input.inner_text === 'string' && input.inner_text.length > 0;
        var keys = Object.keys(attrs);
        function has() {
            for (var i = 0; i < arguments.length; i++) {
                if (keys.indexOf(arguments[i]) !== -1) return true;
            }
            return false;
        }
        if (has('backgroundColor', 'background', 'gradient', 'dimRatio', 'overlayColor')) {
            return 'Change the background colour';
        }
        if (has('textColor', 'color')) return 'Change the text colour';
        if (has('url', 'link', 'linkURL', 'href', 'linkTarget', 'target')) {
            return 'Update the link';
        }
        if (has('align', 'textAlign')) return 'Realign that block';
        if (has('level')) return 'Change the heading level';
        if (has('width', 'height')) return 'Resize that block';
        if (has('caption')) return 'Update the caption';
        if (hasText && keys.length === 0) return 'Rewrite the text in that block';
        return 'Change that block';
    }

    function saveHistory() {
        try {
            var trimmed = messages.length > HISTORY_MAX ? messages.slice(-HISTORY_MAX) : messages;
            // Strip transient ui state when persisting
            var clean = trimmed.map(function (m) {
                return { role: m.role, content: m.content };
            });
            localStorage.setItem(HISTORY_KEY, JSON.stringify(clean));
        } catch (e) { /* ignore */ }
        // Cloud-sync (debounced) so the chat survives a browser-wipe and
        // is reachable from other devices. Same data we just put in
        // localStorage, fired ~1.5s after the last save call.
        if (typeof syncChatDebounced === 'function') syncChatDebounced();
    }
    function loadHistory() {
        try {
            var raw = localStorage.getItem(HISTORY_KEY);
            if (!raw) return;
            var parsed = JSON.parse(raw);
            if (Array.isArray(parsed)) {
                messages = parsed.filter(function (m) {
                    return m && typeof m === 'object' && m.content !== undefined && (m.role === 'user' || m.role === 'assistant' || m.role === 'error');
                });
                // Self-repair: drop orphan tool_result blocks whose
                // matching assistant tool_use no longer exists in the
                // immediately-previous message. Prevents the rail from
                // shipping a structurally-broken conversation to
                // Anthropic on the very next turn after a crash, trim,
                // or re-entrant page-reload.
                messages = repairOrphanToolResults(messages);
            }
        } catch (e) { messages = []; }
    }

    /**
     * Walk the messages array and remove tool_result blocks that are not
     * paired with an immediately-preceding assistant tool_use of the
     * same id. Drops user messages that become empty after the filter.
     * Used both on load (self-repair localStorage state) and before
     * sending to the API (defense in depth — see callChatWithTools).
     */
    function repairOrphanToolResults(input) {
        var out = [];
        for (var i = 0; i < input.length; i++) {
            var m = input[i];
            if (m && m.role === 'user' && Array.isArray(m.content)) {
                var prev = out[out.length - 1];
                var prevIds = {};
                if (prev && prev.role === 'assistant' && Array.isArray(prev.content)) {
                    prev.content.forEach(function (b) {
                        if (b && b.type === 'tool_use' && b.id) prevIds[b.id] = true;
                    });
                }
                var newContent = m.content.filter(function (b) {
                    if (b && b.type === 'tool_result') return !!prevIds[b.tool_use_id];
                    return true;
                });
                if (newContent.length === 0) continue;
                if (newContent.length !== m.content.length) {
                    m = Object.assign({}, m, { content: newContent });
                }
            }
            out.push(m);
        }
        return out;
    }
    /**
     * Mirror of repairOrphanToolResults: every assistant tool_use must be
     * followed by a user message containing a tool_result for that id.
     * If not — because the user typed a fresh message before the chip was
     * confirmed, or a parallel UI path (preview-overlay Apply) raced the
     * chip — Anthropic rejects with 400 "tool_use ids were found without
     * tool_result blocks". This walker repairs orphans by merging
     * synthetic tool_result blocks into the immediately-following user
     * message (converting string content to a blocks array if needed) so
     * the API sees a clean pair. If no user message follows at all, we
     * splice a synthetic one in. Marked is_error:true so Claude treats
     * the call as failed/discarded and continues from the new user input.
     */
    function repairOrphanToolUses(input) {
        var out = input.slice();
        for (var i = 0; i < out.length; i++) {
            var m = out[i];
            if (!m || m.role !== 'assistant' || !Array.isArray(m.content)) continue;
            var openIds = [];
            m.content.forEach(function (b) {
                if (b && b.type === 'tool_use' && b.id) openIds.push(b.id);
            });
            if (!openIds.length) continue;
            var next = out[i + 1];
            var resultIds = {};
            if (next && next.role === 'user' && Array.isArray(next.content)) {
                next.content.forEach(function (b) {
                    if (b && b.type === 'tool_result' && b.tool_use_id) resultIds[b.tool_use_id] = true;
                });
            }
            var missing = openIds.filter(function (id) { return !resultIds[id]; });
            if (!missing.length) continue;
            var synth = missing.map(function (id) {
                return { type: 'tool_result', tool_use_id: id, content: '(superseded — proceed based on the latest user message)', is_error: true };
            });
            if (next && next.role === 'user') {
                // Merge into the existing user turn (avoids two adjacent
                // user messages, which Anthropic also rejects). Tool_result
                // blocks go first per Anthropic convention.
                var nextContent;
                if (Array.isArray(next.content)) {
                    nextContent = synth.concat(next.content);
                } else {
                    // Convert string-text user turn to a blocks array.
                    nextContent = synth.concat([{ type: 'text', text: String(next.content) }]);
                }
                out[i + 1] = Object.assign({}, next, { content: nextContent });
            } else {
                // No following user turn (end of array, or next is an
                // assistant — shouldn't happen but defend anyway). Splice
                // in a synthetic user message.
                out.splice(i + 1, 0, { role: 'user', content: synth });
            }
        }
        return out;
    }

    function clearHistory() {
        try { localStorage.removeItem(HISTORY_KEY); } catch (e) {}
        clearStashedPreview();
    }

    // ------------------------------------------------------------------
    // Cloud-synced chat history (v0.22.0).
    //
    // Every chat gets a UUID stored in localStorage. After each message
    // we debounce 1.5s and POST the full conversation to the proxy.
    // Listing + loading + delete go through the same plugin REST proxy
    // so the browser never sees the raw license key.
    // ------------------------------------------------------------------

    var CURRENT_CHAT_KEY = 'sailwp-copilot-rail.currentChatId';
    var currentChatId = null;
    var chatSyncTimer = null;
    var SYNC_DEBOUNCE_MS = 1500;

    function uuid4() {
        // RFC 4122 v4 — uses crypto.getRandomValues when available, falls
        // back to Math.random (good enough for a chat identifier; we don't
        // need cryptographic strength here, just unique-per-license).
        if (window.crypto && window.crypto.getRandomValues) {
            var b = new Uint8Array(16);
            window.crypto.getRandomValues(b);
            b[6] = (b[6] & 0x0f) | 0x40;
            b[8] = (b[8] & 0x3f) | 0x80;
            var hex = Array.from(b).map(function (x) { return x.toString(16).padStart(2, '0'); }).join('');
            return hex.slice(0,8) + '-' + hex.slice(8,12) + '-' + hex.slice(12,16) + '-' + hex.slice(16,20) + '-' + hex.slice(20);
        }
        return 'cid-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 12);
    }

    function loadCurrentChatId() {
        try {
            var v = localStorage.getItem(CURRENT_CHAT_KEY);
            if (v && /^[A-Za-z0-9_-]{8,64}$/.test(v)) return v;
        } catch (e) {}
        return null;
    }
    function persistCurrentChatId(id) {
        try {
            if (id) localStorage.setItem(CURRENT_CHAT_KEY, id);
            else    localStorage.removeItem(CURRENT_CHAT_KEY);
        } catch (e) {}
    }
    function ensureChatId() {
        if (!currentChatId) {
            currentChatId = loadCurrentChatId() || uuid4();
            persistCurrentChatId(currentChatId);
        }
        return currentChatId;
    }

    function deriveChatTitle() {
        // First user message's first ~60 chars. Falls back to date.
        for (var i = 0; i < messages.length; i++) {
            var m = messages[i];
            if (m.role !== 'user') continue;
            var raw = '';
            if (typeof m.content === 'string') raw = m.content;
            else if (Array.isArray(m.content)) {
                for (var j = 0; j < m.content.length; j++) {
                    var b = m.content[j];
                    if (b && b.type === 'text' && b.text) { raw = b.text; break; }
                }
            }
            // Strip pasted-doc preamble (the user-visible title is usually the
            // tail after the doc, but for simplicity we take the first non-empty)
            raw = String(raw || '').replace(/\s+/g, ' ').trim();
            if (raw) return raw.slice(0, 60);
        }
        var d = new Date();
        return 'Chat ' + d.toLocaleDateString() + ' ' + d.toLocaleTimeString().slice(0, 5);
    }

    function syncChatNow() {
        if (chatSyncTimer) { clearTimeout(chatSyncTimer); chatSyncTimer = null; }
        if (!messages.length) return; // empty chat — nothing to persist
        var id = ensureChatId();
        var title = deriveChatTitle();
        // Strip transient ui state per-message before persisting.
        var clean = messages.map(function (m) { return { role: m.role, content: m.content }; });
        fetch(config.restUrl + 'chat-history/upsert', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.restNonce },
            credentials: 'same-origin',
            body: JSON.stringify({ chat_id: id, title: title, messages: clean })
        }).catch(function () { /* fire-and-forget; localStorage still has the fallback */ });
    }
    function syncChatDebounced() {
        if (chatSyncTimer) clearTimeout(chatSyncTimer);
        chatSyncTimer = setTimeout(syncChatNow, SYNC_DEBOUNCE_MS);
    }

    function fetchChatHistory() {
        return fetch(config.restUrl + 'chat-history/list?limit=30', {
            headers: { 'X-WP-Nonce': config.restNonce },
            credentials: 'same-origin',
        }).then(function (r) { return r.json(); }).then(function (data) {
            if (!data || !data.ok) return [];
            return Array.isArray(data.chats) ? data.chats : [];
        }).catch(function () { return []; });
    }

    function loadChatById(chatId) {
        return fetch(config.restUrl + 'chat-history/get?chat_id=' + encodeURIComponent(chatId), {
            headers: { 'X-WP-Nonce': config.restNonce },
            credentials: 'same-origin',
        }).then(function (r) { return r.json(); }).then(function (data) {
            if (!data || !data.ok || !Array.isArray(data.messages)) return false;
            // Cancel any pending sync of the chat we're about to leave so we
            // don't overwrite the cloud copy with a stale local one.
            if (chatSyncTimer) { clearTimeout(chatSyncTimer); chatSyncTimer = null; }
            messages = data.messages;
            messages = repairOrphanToolResults(messages);
            currentChatId = chatId;
            persistCurrentChatId(currentChatId);
            saveHistory();
            renderMessages();
            return true;
        });
    }

    function deleteChatById(chatId) {
        return fetch(config.restUrl + 'chat-history/delete?chat_id=' + encodeURIComponent(chatId), {
            method: 'DELETE',
            headers: { 'X-WP-Nonce': config.restNonce },
            credentials: 'same-origin',
        }).then(function (r) { return r.json(); }).then(function (data) {
            return !!(data && data.ok);
        }).catch(function () { return false; });
    }

    function renderHistoryPanel(chats) {
        var panel = root.querySelector('.sw-rail-history-panel');
        if (!panel) return;
        if (!chats || !chats.length) {
            panel.innerHTML = '<div class="sw-rail-history-empty">No saved chats yet. Once you start a conversation, it will show up here.</div>';
            return;
        }
        panel.innerHTML = (
            '<div class="sw-rail-history-title">Recent chats</div>' +
            '<ul class="sw-rail-history-list">' +
                chats.map(function (c) {
                    var when = relativeTime(c.updated_at * 1000);
                    var isCurrent = (c.chat_id === currentChatId);
                    return (
                        '<li class="sw-rail-history-item' + (isCurrent ? ' is-current' : '') + '" data-chat-id="' + escapeHtml(c.chat_id) + '">' +
                            '<button type="button" class="sw-rail-history-open" data-chat-id="' + escapeHtml(c.chat_id) + '">' +
                                '<span class="sw-rail-history-item-title">' + escapeHtml(c.title || '(untitled)') + '</span>' +
                                '<span class="sw-rail-history-item-meta">' + escapeHtml(when) + ' · ' + (c.message_count || 0) + ' messages</span>' +
                            '</button>' +
                            '<button type="button" class="sw-rail-history-del" data-chat-id="' + escapeHtml(c.chat_id) + '" aria-label="Delete this chat" title="Delete this chat">×</button>' +
                        '</li>'
                    );
                }).join('') +
            '</ul>'
        );
    }

    function relativeTime(ts) {
        var diff = Math.max(0, Date.now() - ts);
        if (diff < 60 * 1000) return 'just now';
        if (diff < 60 * 60 * 1000) return Math.floor(diff / 60000) + ' min ago';
        if (diff < 24 * 60 * 60 * 1000) return Math.floor(diff / 3600000) + ' hr ago';
        if (diff < 7 * 24 * 60 * 60 * 1000) return Math.floor(diff / 86400000) + ' days ago';
        var d = new Date(ts);
        return d.toLocaleDateString();
    }

    // Precise version-tooltip stamp. Today → "Today, 08:31". Yesterday →
    // "Yesterday, 08:31". Older → "17 May, 08:31" (year added past 1 Jan).
    // Joost 2026-05-17: relative ages ("2 days ago") feel vague once you
    // have many versions; an exact wall-clock time is easier to scan.
    function formatVersionStamp(ms) {
        var d = new Date(ms);
        var hh = String(d.getHours()).padStart(2, '0');
        var mm = String(d.getMinutes()).padStart(2, '0');
        var time = hh + ':' + mm;
        var now = new Date();
        var sameDay = d.toDateString() === now.toDateString();
        if (sameDay) return 'Today, ' + time;
        var yest = new Date(now); yest.setDate(yest.getDate() - 1);
        if (d.toDateString() === yest.toDateString()) return 'Yesterday, ' + time;
        var opts = (d.getFullYear() === now.getFullYear())
            ? { day: 'numeric', month: 'short' }
            : { day: 'numeric', month: 'short', year: 'numeric' };
        return d.toLocaleDateString(undefined, opts) + ', ' + time;
    }

    function toggleHistoryPanel() {
        var panel = root.querySelector('.sw-rail-history-panel');
        var btn   = root.querySelector('.sw-rail-history');
        if (!panel) return;
        if (panel.hidden) {
            panel.hidden = false;
            if (btn) btn.classList.add('is-open');
            // Loading shimmer while we fetch.
            panel.innerHTML = '<div class="sw-rail-history-empty">Loading…</div>';
            fetchChatHistory().then(renderHistoryPanel);
        } else {
            panel.hidden = true;
            if (btn) btn.classList.remove('is-open');
        }
    }
    function hideHistoryPanel() {
        var panel = root.querySelector('.sw-rail-history-panel');
        var btn   = root.querySelector('.sw-rail-history');
        if (panel) panel.hidden = true;
        if (btn) btn.classList.remove('is-open');
    }

    /**
     * Stash the most-recent successful preview so the user can re-open it
     * if the Apply step errored, they closed the overlay by accident, or
     * they navigated away and came back. Scoped by page_id so a stash from
     * About doesn't surface on Contact. Cleared on successful apply or
     * explicit discard.
     */
    function stashPreview(result) {
        if (!result || !result.preview_url) return;
        try {
            localStorage.setItem(PREVIEW_KEY, JSON.stringify({
                preview_id: result.preview_id || null,
                preview_url: result.preview_url,
                page_id: result.page_id || result.original_id || currentPostIdHint() || null,
                ts: Date.now()
            }));
        } catch (e) { /* ignore */ }
    }
    function getStashedPreview() {
        try {
            var raw = localStorage.getItem(PREVIEW_KEY);
            if (!raw) return null;
            var parsed = JSON.parse(raw);
            if (!parsed || !parsed.preview_url) return null;
            return parsed;
        } catch (e) { return null; }
    }
    function clearStashedPreview() {
        try { localStorage.removeItem(PREVIEW_KEY); } catch (e) {}
    }

    function isCollapsed() {
        try {
            // Mobile / phone always starts collapsed. The bottom-sheet UX
            // expects you to tap the FAB to open it. This avoids the
            // edge-case where a leftover "false" state from a desktop
            // session hides the FAB on the phone.
            if (isMobile()) return true;
            var v = localStorage.getItem(STORAGE_KEY);
            // PHP signals via SailWPCopilot.autoOpen that this screen is
            // an editing context (page-edit, inline-edit overlay, designed
            // front-end). On first visit (no localStorage) we expand.
            // localStorage still wins so an explicit user-close sticks.
            if (v === null) {
                var auto = (typeof SailWPCopilot !== 'undefined') && SailWPCopilot.autoOpen;
                return auto ? false : true;
            }
            return v === 'true';
        } catch (e) { return true; }
    }
    function setCollapsed(collapsed) {
        root.setAttribute('data-collapsed', collapsed ? 'true' : 'false');
        try { localStorage.setItem(STORAGE_KEY, collapsed ? 'true' : 'false'); } catch (e) {}
        // Lock body scroll on mobile while the bottom-sheet is open so the
        // page underneath doesn't fight the rail's own scroll.
        try {
            if (collapsed) document.documentElement.classList.remove('swcp-rail-open');
            else if (typeof isMobile === 'function' && isMobile()) document.documentElement.classList.add('swcp-rail-open');
        } catch (e) {}
        if (!collapsed) setTimeout(focusComposer, 50);
    }
    function focusComposer() {
        var ta = root.querySelector('.sw-rail-composer textarea');
        if (ta) ta.focus();
    }

    function renderShell() {
        root.innerHTML = (
            '<button class="sw-rail-toggle" type="button" aria-label="' + escapeHtml(i18n.expand || 'Open SailWP Co-pilot') + '">' + iconSailwp + '</button>' +
            '<div class="sw-rail-panel">' +
                '<div class="sw-rail-header">' +
                    '<div class="sw-rail-title"><span class="sw-rail-title-mark">' + iconSailwp + '</span><span>' + escapeHtml(i18n.title || 'SailWP Co-pilot') + '</span></div>' +
                    '<div class="sw-rail-actions">' +
                        '<button type="button" class="sw-rail-history" title="Recent chats" aria-label="Recent chats">' + iconHistory + '</button>' +
                        '<button type="button" class="sw-rail-new" title="' + escapeHtml(i18n.newChat || 'Start a new chat') + '" aria-label="' + escapeHtml(i18n.newChat || 'Start a new chat') + '">' + iconNew + '</button>' +
                        '<button type="button" class="sw-rail-close" title="' + escapeHtml(i18n.collapse || 'Close') + '" aria-label="' + escapeHtml(i18n.collapse || 'Close') + '">' + iconClose + '</button>' +
                    '</div>' +
                    '<div class="sw-rail-history-panel" hidden></div>' +
                '</div>' +
                '<div class="sw-rail-messages"></div>' +
                '<form class="sw-rail-composer">' +
                    '<div class="sw-rail-attachments"></div>' +
                    '<div class="sw-rail-preview-recovery" data-empty="true"></div>' +
                    '<div class="sw-rail-timeline" data-empty="true"></div>' +
                    '<div class="sw-rail-composer-shell">' +
                        '<textarea id="sw-rail-message" name="message" rows="3" autocomplete="off" placeholder="' + escapeHtml(i18n.placeholder || 'Type, talk, or attach a document…') + '"></textarea>' +
                        '<div class="sw-rail-composer-bar">' +
                            '<button type="button" class="sw-rail-attach" aria-label="Attach a document" title="Attach a document">' + iconAttach + '</button>' +
                            '<input type="file" id="sw-rail-file" name="attachment" class="sw-rail-file-input" accept=".txt,.md,.pdf,.doc,.docx,.png,.jpg,.jpeg,.gif,.webp,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown,image/png,image/jpeg,image/gif,image/webp" hidden />' +
                            '<button type="button" class="sw-rail-pick" aria-label="Click an element on the page" title="Click an element on the page">' + iconPick + '</button>' +
                            '<div class="sw-rail-composer-spacer"></div>' +
                            (voiceSupported
                                ? '<button type="button" class="sw-rail-mic" aria-label="Tap to talk" title="Tap to talk">' + iconMic + '</button>'
                                : '') +
                            '<button type="submit" class="sw-rail-send" aria-label="' + escapeHtml(i18n.send || 'Send') + '" title="' + escapeHtml(i18n.send || 'Send') + '">' + iconSend + '</button>' +
                        '</div>' +
                    '</div>' +
                '</form>' +
                '<div class="sw-rail-quota" data-sw-quota hidden></div>' +
            '</div>'
        );
        renderQuotaFooter();
    }

    /**
     * Subtle "X of 3 generations used this month · resets MMM DD" line
     * under the composer. Free only — Pro and Founder return null from
     * the proxy and the strip stays hidden.
     */
    function renderQuotaFooter() {
        var el = root.querySelector('[data-sw-quota]');
        if (!el) return;
        var q = quotaState;
        // Pro / Founder / unknown — hide footer entirely.
        if (!q || q.heavy_limit === null || q.heavy_limit === undefined) {
            el.hidden = true;
            el.innerHTML = '';
            return;
        }
        // "1 page free" model: heavy_used = 0 pre-design, 1 post-design.
        var firstDesignDone = !!q.first_design_at || (parseInt(q.heavy_used, 10) || 0) >= 1;
        var pricingHref = (config && config.pricingUrl) || 'https://sailwp.com/copilot';
        el.hidden = false;
        if (firstDesignDone) {
            el.innerHTML =
                '<span class="sw-rail-quota-text">Free trial used</span>' +
                ' · <a href="' + escapeHtml(pricingHref) + '" target="_blank" rel="noopener">Upgrade to keep building</a>';
        } else {
            el.innerHTML =
                '<span class="sw-rail-quota-text">Co-pilot Free — your first page design is on us</span>' +
                ' · <a href="' + escapeHtml(pricingHref) + '" target="_blank" rel="noopener">See Pro plans</a>';
        }
    }

    function updateQuotaFromResponse(data) {
        if (!data || typeof data !== 'object' || !data.quota) return;
        quotaState = data.quota;
        renderQuotaFooter();
    }

    function renderSuggestionSections() {
        if (!Array.isArray(suggestions) || suggestions.length === 0) return '';
        if (typeof suggestions[0] === 'string') {
            return (
                '<div class="sw-rail-suggestion-section">' +
                    '<div class="sw-rail-suggestions">' +
                        suggestions.map(function (s) {
                            return '<button type="button" class="sw-rail-suggestion" data-text="' + escapeHtml(s) + '">' + escapeHtml(s) + '</button>';
                        }).join('') +
                    '</div>' +
                '</div>'
            );
        }
        return suggestions.map(function (sec) {
            return (
                '<div class="sw-rail-suggestion-section">' +
                    '<div class="sw-rail-suggestion-label">' + escapeHtml(sec.label || '') + '</div>' +
                    '<div class="sw-rail-suggestions">' +
                        (sec.items || []).map(function (item) {
                            return '<button type="button" class="sw-rail-suggestion" data-text="' + escapeHtml(item) + '">' + escapeHtml(item) + '</button>';
                        }).join('') +
                    '</div>' +
                '</div>'
            );
        }).join('');
    }

    function renderMessageBubble(m, mIdx) {
        if (m.role === 'user') {
            // user content is a string (possibly with prepended doc) or an array (tool_result blocks)
            if (typeof m.content === 'string') {
                var ui = m.ui || {};
                var visibleText = ui.displayText || m.content;
                var atts = Array.isArray(ui.attachments) ? ui.attachments : [];
                var attHtml = '';
                if (atts.length) {
                    attHtml = '<div class="sw-rail-bubble-attachments">' + atts.map(function (n) {
                        return '<span class="sw-rail-bubble-attachment">' + escapeHtml(n) + '</span>';
                    }).join('') + '</div>';
                }
                return '<div class="sw-rail-msg is-user"><div class="sw-rail-bubble">' + attHtml + (visibleText ? '<p>' + escapeHtml(visibleText) + '</p>' : '') + '</div></div>';
            }
            // Tool-result rows are not rendered separately — the action
            // chip above already shows the done-or-error state. Less noise.
            return '';
        }
        if (m.role === 'error') {
            return '<div class="sw-rail-msg is-error"><div class="sw-rail-bubble"><p>' + escapeHtml(m.content) + '</p></div></div>';
        }
        // assistant
        var paywallCard = renderPaywallCard(m.ui && m.ui.paywallPlans, mIdx);
        if (typeof m.content === 'string') {
            return '<div class="sw-rail-msg is-assistant"><div class="sw-rail-bubble">' + renderMarkdown(m.content) + '</div></div>' + paywallCard;
        }
        if (!Array.isArray(m.content)) return '';
        var blocksHtml = m.content.map(function (block, bIdx) {
            if (block.type === 'text') {
                if (!String(block.text || '').trim()) return '';
                return '<div class="sw-rail-msg is-assistant"><div class="sw-rail-bubble">' + renderMarkdown(block.text) + '</div></div>';
            }
            if (block.type === 'tool_use') {
                var ui = m.ui || {};
                var status = (ui.toolStatus && ui.toolStatus[block.id]) || 'pending';
                var result = (ui.toolResults && ui.toolResults[block.id]) || null;
                return renderActionChip(block, status, result, mIdx, bIdx);
            }
            return '';
        }).join('');
        return blocksHtml + paywallCard;
    }

    /**
     * "1 page free" paywall — when chat-with-tools returns paywall=true,
     * render the Monthly / Yearly plan-buttons card right under the
     * assistant message. Same plan-button DOM shape as the limit_reached
     * tool-result upgrade-card so the existing click handler works.
     */
    function renderPaywallCard(plans, mIdx) {
        if (!Array.isArray(plans) || plans.length === 0) return '';
        var planButtons = plans.map(function (p) {
            return (
                '<button type="button" class="sw-rail-upgrade-plan" data-plan="' + escapeHtml(p.id) + '" data-msg="' + mIdx + '">' +
                    '<span class="sw-rail-upgrade-plan-row">' +
                        '<span class="sw-rail-upgrade-plan-label">' + escapeHtml(p.label) + '</span>' +
                        '<span class="sw-rail-upgrade-plan-price">' + escapeHtml(p.price || '') + (p.period ? '<span class="sw-rail-upgrade-plan-period">' + escapeHtml(p.period) + '</span>' : '') + '</span>' +
                    '</span>' +
                    (p.note ? '<span class="sw-rail-upgrade-plan-note">' + escapeHtml(p.note) + '</span>' : '') +
                '</button>'
            );
        }).join('');
        return (
            '<div class="sw-rail-msg is-assistant"><div class="sw-rail-bubble sw-rail-paywall-bubble">' +
                '<div class="sw-rail-upgrade-card">' +
                    '<div class="sw-rail-upgrade-title">Co-pilot Pro</div>' +
                    '<div class="sw-rail-upgrade-plans">' + planButtons + '</div>' +
                    '<div class="sw-rail-upgrade-footnote">Cancel anytime · VAT added at checkout</div>' +
                '</div>' +
            '</div></div>'
        );
    }

    function renderActionChip(toolUse, status, result, mIdx, bIdx) {
        var labelHtml = chipLabelHtml(toolUse);
        var idAttr = 'data-msg="' + mIdx + '" data-block="' + bIdx + '" data-tool-id="' + escapeHtml(toolUse.id) + '" data-tool-name="' + escapeHtml(toolUse.name) + '"';
        var body = '';
        if (status === 'pending') {
            // Auto-confirm-eligible chips render as "running" up front so the
            // user never sees a Yes/Cancel flash for tools we are about to
            // fire automatically (e.g. generate_image after the user already
            // approved a multi-image plan in plain text).
            if (willAutoConfirm(mIdx, toolUse)) {
                body = '<div class="sw-rail-chip-status sw-rail-chip-status-running"><span class="sw-rail-chip-thinking">' + iconSailwp + '</span><span>Working on it…</span></div>';
            } else {
                body = (
                    '<div class="sw-rail-chip-actions">' +
                        '<button type="button" class="sw-rail-chip-confirm" ' + idAttr + '>Yes, do it</button>' +
                        '<button type="button" class="sw-rail-chip-cancel"  ' + idAttr + '>Cancel</button>' +
                    '</div>'
                );
            }
        } else if (status === 'running') {
            body = '<div class="sw-rail-chip-status sw-rail-chip-status-running"><span class="sw-rail-chip-thinking">' + iconSailwp + '</span><span>Working on it…</span></div>';
        } else if (status === 'cancelled') {
            body = '<div class="sw-rail-chip-status is-cancelled">Cancelled.</div>';
        } else if (status === 'error') {
            // Quota/limit hit → show an in-chat upgrade card with plan buttons
            // that open a Stripe Embedded Checkout modal in-place. Keeps the
            // user inside the conversation; no bouncing out to a hosted tab.
            if (result && result.limit_reached && result.inline_upgrade && Array.isArray(result.inline_upgrade.plans)) {
                var plans = result.inline_upgrade.plans;
                var planButtons = plans.map(function (p) {
                    return (
                        '<button type="button" class="sw-rail-upgrade-plan" data-plan="' + escapeHtml(p.id) + '" ' + idAttr + '>' +
                            '<span class="sw-rail-upgrade-plan-row">' +
                                '<span class="sw-rail-upgrade-plan-label">' + escapeHtml(p.label) + '</span>' +
                                '<span class="sw-rail-upgrade-plan-price">' + escapeHtml(p.price || '') + (p.period ? '<span class="sw-rail-upgrade-plan-period">' + escapeHtml(p.period) + '</span>' : '') + '</span>' +
                            '</span>' +
                            (p.note ? '<span class="sw-rail-upgrade-plan-note">' + escapeHtml(p.note) + '</span>' : '') +
                        '</button>'
                    );
                }).join('');
                body = (
                    '<div class="sw-rail-upgrade-card">' +
                        '<div class="sw-rail-upgrade-title">Co-pilot Pro</div>' +
                        '<div class="sw-rail-upgrade-msg">' + renderMarkdown(result.message || 'You have hit your plan limit.') + '</div>' +
                        '<div class="sw-rail-upgrade-plans">' + planButtons + '</div>' +
                        '<div class="sw-rail-upgrade-footnote">Cancel anytime · VAT added at checkout</div>' +
                    '</div>'
                );
            } else {
                body = '<div class="sw-rail-chip-status is-error">' + escapeHtml((result && result.message) || 'Could not finish.') + '</div>';
            }
        } else if (status === 'done') {
            var msg = (result && result.message) || 'Done.';
            var links = '';
            if (result && result.view_url) {
                links += ' <a href="' + escapeHtml(result.view_url) + '" target="_blank" rel="noopener">View page</a>';
            }
            if (result && result.edit_url) {
                links += ' <a href="' + escapeHtml(result.edit_url) + '" target="_blank" rel="noopener">Open in editor</a>';
            }
            var undoBtn = '';
            if (result && result.undo && result.undo.tool && result.undo.input) {
                var undoLabel = result.undo.label || 'Undo';
                undoBtn = ' <button type="button" class="sw-rail-chip-undo" ' + idAttr + '>' + escapeHtml(undoLabel) + '</button>';
            }
            body = '<div class="sw-rail-chip-status is-done">' + escapeHtml(msg) + links + undoBtn + '</div>';
        }
        return (
            '<div class="sw-rail-chip" data-status="' + status + '">' +
                labelHtml +
                body +
            '</div>'
        );
    }

    function renderMessages() {
        var area = root.querySelector('.sw-rail-messages');
        if (!area) return;
        if (messages.length === 0) {
            area.innerHTML = (
                '<div class="sw-rail-empty">' +
                    '<h3>' + escapeHtml(i18n.emptyTitle || 'How can I help?') + '</h3>' +
                    '<p>' + escapeHtml(i18n.emptySub || '') + '</p>' +
                    renderSuggestionSections() +
                '</div>'
            );
        } else {
            var html = messages.map(function (m, idx) { return renderMessageBubble(m, idx); }).join('');
            if (pending) {
                html += '<div class="sw-rail-msg is-assistant"><div class="sw-rail-bubble sw-rail-thinking-bubble"><span class="sw-rail-thinking">' + iconSailwp + '</span></div></div>';
            }
            area.innerHTML = html;
        }
        area.scrollTop = area.scrollHeight;
        renderPreviewRecovery();
    }

    /**
     * Send messages to /chat-with-tools. The server returns:
     *   { stop_reason, content: [...], model }
     * If content has any tool_use blocks, render action-chips; user confirms.
     * Otherwise, render text and end the turn.
     */
    function callChatWithTools() {
        var apiMessages = messages
            .filter(function (m) { return m.role !== 'error' && m.content !== undefined; })
            .map(function (m) {
                // For user turns with un-consumed image/pdf attachments, inflate
                // into a content-blocks array so Claude sees the binary content.
                if (m.role === 'user' && Array.isArray(m.apiAttachments)) {
                    var fresh = m.apiAttachments.filter(function (a) { return !a.consumed; });
                    if (fresh.length && typeof m.content === 'string') {
                        var blocks = [{ type: 'text', text: m.content }];
                        fresh.forEach(function (a) { blocks.push(a.block); });
                        return { role: m.role, content: blocks };
                    }
                }
                return { role: m.role, content: m.content };
            });

        // Defense in depth: Anthropic enforces a STRICT pairing — every
        // tool_result block must have a matching tool_use block in the
        // IMMEDIATELY-PREVIOUS assistant message, AND every tool_use must
        // have a matching tool_result in the immediately-FOLLOWING user
        // message. Either side of the pairing being broken trips a 400.
        // Both can happen: tool_result orphans from HISTORY_MAX truncation
        // or parallel UI confirms; tool_use orphans when a fresh user text
        // message lands while a chip is still pending. Repair both.
        apiMessages = repairOrphanToolResults(apiMessages);
        apiMessages = repairOrphanToolUses(apiMessages);

        return fetch(config.restUrl + 'chat-with-tools', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-WP-Nonce': config.restNonce
            },
            body: JSON.stringify({
                messages: apiMessages,
                context: config.context || {}
            })
        }).then(function (res) {
            return res.text().then(function (text) {
                var data = null;
                try { data = JSON.parse(text); } catch (e) { data = null; }
                if (!data || typeof data !== 'object') {
                    throw new Error('The Co-pilot ran into a server error. Try again in a moment.');
                }
                if (!res.ok) {
                    var msg = (data.message || data.code) || ('HTTP ' + res.status);
                    throw new Error(msg);
                }
                updateQuotaFromResponse(data);
                return data;
            });
        });
    }

    function send(text) {
        var trimmed = (text || '').trim();
        var attached = pendingAttachments.slice();
        if (!trimmed && attached.length === 0) return;
        if (pending) return;

        // Word docs need server-side mammoth extraction. Images/PDFs go
        // straight as Anthropic content blocks. Plain text is already extracted.
        var needExtraction = attached.filter(function (a) { return a.kind === 'word' && !a.text; });
        // Wait for in-flight image uploads so we can include the URL in
        // the message context (Sonnet sees it; design tool uses it).
        var pendingImageUploads = attached
            .filter(function (a) { return a.kind === 'image' && a.uploadPromise && !a.url; })
            .map(function (a) { return a.uploadPromise; });

        var promise = Promise.resolve();
        if (pendingImageUploads.length) {
            pending = true;
            renderMessages();
            promise = promise.then(function () { return Promise.all(pendingImageUploads); });
        }
        if (needExtraction.length) {
            pending = true;
            renderMessages();
            promise = Promise.all(needExtraction.map(function (a) {
                return fetch(config.restUrl + 'extract-text', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-WP-Nonce': config.restNonce
                    },
                    body: JSON.stringify({ name: a.name, kind: a.kind, dataUrl: a.dataUrl })
                }).then(function (res) {
                    return res.json().then(function (data) {
                        if (!res.ok || !data.ok) throw new Error((data && data.message) || 'Could not read file');
                        a.text = String(data.text || '');
                        delete a.dataUrl;
                    });
                });
            })).catch(function (err) {
                pending = false;
                pushFlashError('Could not read attached file: ' + (err && err.message ? err.message : 'unknown'));
                throw err;
            });
        }

        promise.then(function () {
            pendingAttachments = [];
            renderAttachments();

            var prepended = '';
            var attachmentLabels = [];
            var apiAttachments = []; // image/pdf — sent as content blocks (not text)
            attached.forEach(function (a) {
                attachmentLabels.push(a.name);
                if (a.text) {
                    prepended += 'Here is the document "' + a.name + '" (the user attached this):\n\n' + a.text + '\n\n---\n\n';
                }
                if (a.kind === 'image' && a.url) {
                    prepended += 'The user uploaded an image "' + a.name + '" — it is now in their WordPress media library at: ' + a.url + '\n(When you call design_page_preview, include this URL in additional_image_urls so the design uses the photo.)\n\n';
                }
                if (a.block) {
                    apiAttachments.push({ name: a.name, kind: a.kind, block: a.block, url: a.url || null, consumed: false });
                }
            });
            // Picked-element context line. Lives in the prompt only — not
            // shown to the user as text (the chip already showed it).
            var picked = consumePickedContext();
            var pickPrefix = picked ? picked.line + '\n' : '';

            // Browser text selection — if the user highlighted text on the
            // page (outside the rail itself) we forward it as context so the
            // Co-pilot can act on the exact thing they're pointing at,
            // without having to ask "which text exactly?".
            var selPrefix = '';
            try {
                var s = window.getSelection && window.getSelection();
                if (s && s.rangeCount && !s.isCollapsed) {
                    var anchor = s.anchorNode;
                    if (anchor && anchor.nodeType === 3) anchor = anchor.parentNode;
                    var insideRail = anchor && anchor.closest && (anchor.closest('.sw-rail-root') || anchor.closest('.sw-rail-pick-hud'));
                    if (!insideRail) {
                        var selText = String(s.toString() || '').trim();
                        if (selText.length > 500) selText = selText.slice(0, 500) + '…';
                        if (selText) {
                            selPrefix = '[Selected text]: "' + selText.replace(/"/g, '\\"') + '"\n';
                        }
                    }
                }
            } catch (_selErr) { /* selection unsupported — silently skip */ }

            var fullContent = selPrefix + pickPrefix + prepended + trimmed;
            if (!fullContent.trim()) return;

            messages.push({
                role: 'user',
                content: fullContent,
                ui: { displayText: trimmed, attachments: attachmentLabels, picked: picked ? picked.label : null },
                apiAttachments: apiAttachments
            });
            saveHistory();
            runChatTurn();
        }).catch(function () { /* error already shown */ });
    }

    function runChatTurn() {
        pending = true;
        renderMessages();
        callChatWithTools()
            .then(function (data) {
                var content = Array.isArray(data.content) ? data.content : [];
                var ui = { toolStatus: {}, toolResults: {} };
                // "1 page free" paywall — server returns paywall=true +
                // inline_upgrade.plans when a free license tries to chat
                // after their first design is delivered. Attach to ui so
                // renderMessageBubble appends a plan-buttons card.
                if (data && data.paywall === true && data.inline_upgrade && Array.isArray(data.inline_upgrade.plans)) {
                    ui.paywallPlans = data.inline_upgrade.plans;
                }
                messages.push({
                    role: 'assistant',
                    content: content,
                    ui: ui
                });
                // Mark all just-sent attachments as consumed — Claude has
                // seen them; subsequent tool round-trips reuse the same
                // conversation context without re-sending the binary.
                messages.forEach(function (m) {
                    if (Array.isArray(m.apiAttachments)) {
                        m.apiAttachments.forEach(function (a) { a.consumed = true; });
                    }
                });
            })
            .catch(function (err) {
                messages.push({ role: 'error', content: (i18n.errorPrefix || '') + (err && err.message ? err.message : 'request failed') });
            })
            .then(function () {
                pending = false;
                saveHistory();
                renderMessages();
                autoConfirmEligible();
            });
    }

    /**
     * Tools that are non-destructive and creative-flow oriented — once the
     * user has agreed to the high-level plan in plain text, asking "Yes, do
     * it" again on each follow-up call breaks the magic. We auto-confirm
     * these as soon as the chip appears.
     *
     * Strict scoping: only auto-fires when EVERY pending tool_use in the
     * current assistant message is on this list, so a mixed turn (e.g.
     * generate_image + apply_page_design) still waits for explicit
     * confirmation on the destructive call. Also bails if the tool was
     * already confirmed/cancelled — runs once per chip.
     */
    var AUTO_CONFIRM_TOOLS = ['generate_image', 'navigate_to'];

    /**
     * True when the given tool_use will be auto-confirmed (used both at
     * render-time to skip the Yes/Cancel buttons and at run-time to fire
     * confirmTool automatically). Mirrors the gate in autoConfirmEligible
     * so the two stay aligned.
     */
    function willAutoConfirm(mIdx, toolUse) {
        if (!toolUse || AUTO_CONFIRM_TOOLS.indexOf(toolUse.name) === -1) return false;
        var m = messages[mIdx];
        if (!m || !Array.isArray(m.content)) return false;
        var toolUses = m.content.filter(function (b) { return b.type === 'tool_use'; });
        if (!toolUses.length) return false;
        return toolUses.every(function (b) {
            return AUTO_CONFIRM_TOOLS.indexOf(b.name) !== -1;
        });
    }

    /**
     * Does message[mIdx+1] (a user turn) contain a tool_result block whose
     * tool_use_id matches `toolUseId`? Authoritative "this tool already ran"
     * signal that survives page reload — `ui.toolStatus` is in-memory only.
     */
    function toolHasResult(mIdx, toolUseId) {
        var next = messages[mIdx + 1];
        if (!next || next.role !== 'user' || !Array.isArray(next.content)) return false;
        return next.content.some(function (b) {
            return b && b.type === 'tool_result' && b.tool_use_id === toolUseId;
        });
    }

    function autoConfirmEligible() {
        if (pending) return;
        for (var mi = 0; mi < messages.length; mi++) {
            var m = messages[mi];
            if (m.role !== 'assistant' || !Array.isArray(m.content)) continue;
            var toolUses = m.content.filter(function (b) { return b.type === 'tool_use'; });
            if (!toolUses.length) continue;
            // Only auto-fire if every tool in the turn is auto-confirm-safe.
            var allEligible = toolUses.every(function (b) {
                return AUTO_CONFIRM_TOOLS.indexOf(b.name) !== -1;
            });
            if (!allEligible) continue;
            var statuses = (m.ui && m.ui.toolStatus) || {};
            for (var i = 0; i < toolUses.length; i++) {
                var tu = toolUses[i];
                // After a page reload (localStorage), ui.toolStatus is gone but
                // the tool_result is still in the next message. Treat that as
                // "done" — otherwise we double-fire auto-confirm tools (e.g.
                // navigate_to → second redirect + duplicate tool_result that
                // breaks the Anthropic API's tool-call/result pairing).
                if (toolHasResult(mi, tu.id)) continue;
                var s = statuses[tu.id];
                if (!s || s === 'pending') {
                    confirmTool(mi, tu.id);
                    return; // confirmTool sets pending=true; the chain resumes when it finishes
                }
            }
        }
    }

    /**
     * Walk messages newest-first looking for an assistant tool_use of the
     * given name that does not yet have a matching tool_result. Returns
     * { mIdx, toolUseId } or null. Used by the preview-overlay apply button
     * so it can route to confirmTool instead of pushing a new user-text
     * message (which would orphan the open tool_use and trigger a 400 from
     * the API).
     */
    function findPendingToolUse(name) {
        for (var i = messages.length - 1; i >= 0; i--) {
            var m = messages[i];
            if (!m || m.role !== 'assistant' || !Array.isArray(m.content)) continue;
            for (var j = 0; j < m.content.length; j++) {
                var b = m.content[j];
                if (b && b.type === 'tool_use' && b.name === name && b.id) {
                    if (!toolHasResult(i, b.id)) {
                        return { mIdx: i, toolUseId: b.id };
                    }
                }
            }
        }
        return null;
    }

    function findToolUse(mIdx, toolUseId) {
        var m = messages[mIdx];
        if (!m || !Array.isArray(m.content)) return null;
        for (var i = 0; i < m.content.length; i++) {
            if (m.content[i].type === 'tool_use' && m.content[i].id === toolUseId) {
                return m.content[i];
            }
        }
        return null;
    }

    function setToolStatus(mIdx, toolUseId, status, result) {
        var m = messages[mIdx];
        if (!m) return;
        if (!m.ui) m.ui = { toolStatus: {}, toolResults: {} };
        m.ui.toolStatus[toolUseId] = status;
        if (result !== undefined) m.ui.toolResults[toolUseId] = result;
    }

    /**
     * On confirm: POST to /execute, then push tool_result and continue
     * the conversation by calling /chat-with-tools again.
     */
    function confirmTool(mIdx, toolUseId) {
        var toolUse = findToolUse(mIdx, toolUseId);
        if (!toolUse || pending) return;
        var input = toolUse.input || {};
        // Resolve "current" page server-side using the editor's current post ID,
        // available on edit screens via window.wp.data or the URL.
        var pageIdHint = currentPostIdHint();

        // Auto-inject all photos uploaded so far when Claude calls the
        // design tool — even if it forgot to pass them itself.
        if (toolUse.name === 'design_page_preview') {
            var urls = collectUploadedImageUrls();
            if (urls.length) {
                var existing = Array.isArray(input.additional_image_urls) ? input.additional_image_urls : [];
                input = Object.assign({}, input, {
                    additional_image_urls: existing.concat(urls.filter(function (u) { return existing.indexOf(u) === -1; }))
                });
            }
        }

        setToolStatus(mIdx, toolUseId, 'running');
        renderMessages();
        pending = true;

        fetch(config.restUrl + 'execute', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-WP-Nonce': config.restNonce
            },
            body: JSON.stringify({
                tool: toolUse.name,
                input: input,
                page_id: pageIdHint
            })
        }).then(function (res) {
            // Defend against non-JSON bodies (WP critical-error HTML, gateway
            // pages, partial PHP fatals that escape the shield). Reading
            // text() and parsing manually means a friendly chat error
            // instead of "Unexpected token <" or raw HTML in the bubble.
            return res.text().then(function (text) {
                var data = null;
                try { data = JSON.parse(text); } catch (e) { data = null; }
                if (!data || typeof data !== 'object') {
                    throw new Error('The Co-pilot ran into a server error. Try again, or refresh the page if it keeps happening.');
                }
                if (!res.ok) {
                    throw new Error((data.message || data.code) || ('HTTP ' + res.status));
                }
                return data;
            });
        }).then(function (result) {
            // Async design-job path: the tool returned a pending job_id, not
            // a final result. Poll the proxy's status endpoint (CORS-open)
            // every 3s; once done, ask our /finalize-design REST endpoint
            // to create the preview post. The chip stays in 'running' state
            // throughout. This keeps every individual HTTP request short so
            // Nginx/PHP-FPM upstream timeouts never trip.
            if (result && result.ok && result.pending && result.job_id && result.status_endpoint) {
                pollDesignJob(result, mIdx, toolUseId, toolUse);
                return; // skip the normal tool_result push — the poller will do it
            }
            var ok = !!result.ok;
            setToolStatus(mIdx, toolUseId, ok ? 'done' : 'error', result);
            // Push tool_result back so Claude can react. Append the structured
            // fields it needs in subsequent tool calls (preview_id, urls, etc).
            var resultText = result.message || (ok ? 'Done.' : 'Failed.');
            if (ok || result.sparse || result.limit_reached) {
                var structured = [];
                ['preview_id', 'preview_url', 'original_id', 'view_url', 'edit_url', 'page_id', 'email', 'page_title', 'sparse', 'body_chars', 'image_count', 'upgrade_url', 'limit_reached', 'created', 'post_id', 'target_url', 'target_label'].forEach(function (k) {
                    if (result[k] !== undefined && result[k] !== null && result[k] !== '') {
                        structured.push(k + ': ' + result[k]);
                    }
                });
                if (Array.isArray(result.headings_found) && result.headings_found.length) {
                    structured.push('headings_found: ' + JSON.stringify(result.headings_found));
                }
                if (structured.length) resultText += '\n\n' + structured.join('\n');
            }
            messages.push({
                role: 'user',
                content: [{ type: 'tool_result', tool_use_id: toolUseId, content: resultText, is_error: !ok }]
            });
            saveHistory();
            renderMessages();

            // If we just got a preview, slide it into view automatically so the
            // user does not have to click a link.
            if (ok && (toolUse.name === 'design_page_preview' || toolUse.name === 'iterate_page_design' || toolUse.name === 'redesign_page') && result.preview_url) {
                stashPreview(result);
                showPreviewPanel(result);
            }
            // navigate_to returns a target_url. Persist history first, then
            // redirect the whole tab so the user lands on the requested page
            // with the rail still mounted in place. We delay a tick so the
            // chip shows "done" before the reload.
            if (ok && toolUse.name === 'navigate_to' && result.target_url) {
                try { saveHistory(); } catch (_) {}
                setTimeout(function () {
                    try { window.location.href = result.target_url; }
                    catch (e) { /* swallow — defensive */ }
                }, 350);
                return; // skip runChatTurn — page is about to unload
            }
            // write_blog_draft live-inject: if we're sitting in a Gutenberg
            // post editor, dispatch the new title + parsed blocks + featured
            // image straight into wp.data so the article appears under the
            // user's cursor without a reload. v0.25.4 (Joost 2026-05-17):
            // Joost said the prior flow (write-to-separate-draft + redirect)
            // "voelt niet magisch". This is the magic version.
            if (ok && toolUse.name === 'write_blog_draft' && result.gutenberg_content) {
                var injected = false;
                try {
                    if (window.wp && window.wp.data && window.wp.blocks && window.wp.data.dispatch) {
                        var ed   = window.wp.data.dispatch('core/editor');
                        var blkE = window.wp.data.dispatch('core/block-editor');
                        if (ed && blkE && typeof window.wp.blocks.parse === 'function') {
                            var blocks = window.wp.blocks.parse(String(result.gutenberg_content));
                            // Title + excerpt + featured image via editPost
                            var edits = { title: String(result.title || '') };
                            if (result.excerpt) edits.excerpt = String(result.excerpt);
                            if (result.image_id) edits.featured_media = parseInt(result.image_id, 10);
                            if (typeof ed.editPost === 'function') ed.editPost(edits);
                            // Replace all blocks with the generated content
                            if (typeof blkE.resetBlocks === 'function') blkE.resetBlocks(blocks);
                            else if (typeof ed.resetEditorBlocks === 'function') ed.resetEditorBlocks(blocks);
                            injected = true;
                        }
                    }
                } catch (e) { /* swallow — fall back to redirect below */ }
                if (injected) {
                    // Skip the redirect — the user is already looking at it.
                    saveHistory();
                    setTimeout(function () { runChatTurn(); }, 50);
                    return;
                }
            }
            // Generic redirect_to — any tool result can set this. Used by
            // write_blog_draft when it had to mint a fresh draft (the user
            // was on post-new.php with no post-id), so the rail takes them
            // to the newly minted edit screen instead of leaving them on
            // an empty form. v0.25.1 (Joost 2026-05-17).
            if (ok && result.redirect_to) {
                try { saveHistory(); } catch (_) {}
                setTimeout(function () {
                    try { window.location.href = result.redirect_to; }
                    catch (e) { /* swallow — defensive */ }
                }, 600);
                return;
            }
            // If we just applied a design, close the preview panel and
            // clear the recovery stash — the preview is now the live page.
            if (ok && toolUse.name === 'apply_page_design') {
                hidePreviewPanel();
                clearStashedPreview();
                renderPreviewRecovery();
            }
            // Auto-refresh the visible page so the user sees the change
            // without having to hit F5 themselves. The tool opts in via
            // `requires_refresh: true`. We delay 2.5s so the user can read
            // the success message + the Undo button stays clickable in
            // case they want to back out before reload. Clicking Undo
            // cancels this timer (see undoTool).
            if (ok && result.requires_refresh) {
                schedulePageReload(2500);
            }

            // After any successful edit we likely have a new revision in
            // the WP database. Refresh the dot timeline so the user can
            // see the new version land.
            if (ok) fetchTimeline();

            // Continue the conversation so Claude can summarise / suggest next.
            runChatTurn();
        }).catch(function (err) {
            setToolStatus(mIdx, toolUseId, 'error', { ok: false, message: err && err.message ? err.message : 'request failed' });
            // Still send a tool_result back so the loop is consistent.
            messages.push({
                role: 'user',
                content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'Execution error: ' + (err && err.message ? err.message : 'unknown'), is_error: true }]
            });
            pending = false;
            saveHistory();
            renderMessages();
        });
    }

    function isMobile() {
        return (window.matchMedia && window.matchMedia('(max-width: 720px)').matches) || window.innerWidth <= 720;
    }

    /**
     * Show a full-area preview overlay (left of the rail) so the user can
     * SEE the new design without clicking a link. Apply / Discard buttons
     * fold back into the chat as if the user typed a reply.
     *
     * On phone-sized screens the overlay covers the whole viewport and we
     * collapse the rail so the design has all the room.
     */
    function showPreviewPanel(result) {
        hidePreviewPanel();
        if (!result || !result.preview_url) return;
        if (isMobile()) setCollapsed(true);
        var overlay = document.createElement('div');
        overlay.id = 'swcp-preview-overlay';
        overlay.innerHTML = (
            '<div class="swcp-preview-banner">' +
                '<div class="swcp-preview-banner-title">Preview · your live page is unchanged</div>' +
                '<div class="swcp-preview-banner-actions">' +
                    '<button type="button" class="swcp-preview-btn-discard">Discard</button>' +
                    '<button type="button" class="swcp-preview-btn-apply">Apply this design</button>' +
                '</div>' +
            '</div>' +
            '<iframe class="swcp-preview-iframe" src="' + escapeHtml(result.preview_url) + '" loading="lazy" scrolling="yes"></iframe>'
        );
        document.body.appendChild(overlay);
        overlay.querySelector('.swcp-preview-btn-apply').addEventListener('click', function () {
            if (isMobile()) setCollapsed(false); // re-open rail so user sees the success state
            // Direct apply — bypass the Claude round-trip entirely. Clicking
            // "Apply this design" on the preview banner is unambiguous, so
            // there's no reason to push "Yes, apply this design." as a chat
            // message and wait two model turns for the tool to fire. We call
            // the REST endpoint directly and update the banner in-place to a
            // success state. The iframe keeps showing the design (the live
            // page now has the same content), so there's no white-page
            // navigation flash either.
            var applyBtn  = this;
            var banner    = overlay.querySelector('.swcp-preview-banner');
            var discardBtn = banner.querySelector('.swcp-preview-btn-discard');
            var titleEl    = banner.querySelector('.swcp-preview-banner-title');
            applyBtn.disabled = true;
            applyBtn.textContent = 'Saving…';
            if (discardBtn) discardBtn.disabled = true;
            if (titleEl) titleEl.textContent = 'Applying your new design…';
            var prevErr = banner.querySelector('.swcp-preview-banner-error');
            if (prevErr) prevErr.remove();

            // Apply-bug repro (16 May 2026): Joost saw "Failed to fetch" on
            // a single apply. Root cause unverified — could be a transient
            // network blip, a stale nonce, or an nginx hiccup. We harden:
            //   1. Try once. If fetch throws (network / aborted / mixed),
            //      wait 1.2s and retry once. Most "Failed to fetch" cases
            //      pass on retry.
            //   2. Surface a clearer error message if both attempts fail so
            //      the user knows whether to refresh or reach out.
            //   3. Fire-and-forget a diagnostic ping to the proxy so we can
            //      trace future fails server-side.
            function applyOnce() {
                return fetch(config.restUrl + 'apply-design', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.restNonce },
                    body: JSON.stringify({ preview_id: result.preview_id }),
                    credentials: 'same-origin'
                }).then(function (r) {
                    return r.text().then(function (txt) {
                        var d = null; try { d = JSON.parse(txt); } catch (_) {}
                        return { code: r.status, data: d || {} };
                    });
                });
            }
            function logApplyFailDiagnostic(stage, err, code) {
                try {
                    if (!config.aiProxyUrl) return;
                    fetch(config.aiProxyUrl + '/api/v1/event', {
                        method: 'POST',
                        keepalive: true,
                        headers: { 'Content-Type': 'application/json', 'X-License-Key': config.licenseKey || '' },
                        body: JSON.stringify({
                            event_name: 'apply_design_failed',
                            details: {
                                stage: stage,
                                code: code || null,
                                message: (err && err.message) ? String(err.message).slice(0, 240) : null,
                                ua: navigator.userAgent.slice(0, 120),
                                ts: Date.now()
                            }
                        })
                    }).catch(function(){});
                } catch (_) {}
            }
            applyOnce().catch(function (err) {
                // Network-level failure (TypeError: Failed to fetch). Wait a
                // beat and try once more before giving up.
                if (titleEl) titleEl.textContent = 'Hiccup — retrying…';
                logApplyFailDiagnostic('first-attempt-network', err, null);
                return new Promise(function (resolve) { setTimeout(resolve, 1200); }).then(applyOnce);
            }).then(function (wrapped) {
                var data = wrapped.data;
                if (wrapped.code >= 400 || !data.ok) {
                    var msg = (data && data.message) ? data.message : ('Server returned ' + wrapped.code + '. Refresh the page and try again.');
                    var e = new Error(msg);
                    e.__code = wrapped.code;
                    throw e;
                }
                clearStashedPreview();
                renderPreviewRecovery();
                // Banner becomes a slim "Saved" bar. Iframe keeps showing
                // the design — the live page is now identical to the
                // preview content, so there's nothing to refresh.
                if (titleEl) titleEl.textContent = '✓ Live · your home page is now this design';
                banner.classList.add('is-success');
                var actions = banner.querySelector('.swcp-preview-banner-actions');
                if (actions) {
                    actions.innerHTML = (
                        '<a class="swcp-preview-btn-open" target="_top" href="' + escapeHtml(data.view_url || '#') + '">Open page</a>' +
                        '<button type="button" class="swcp-preview-btn-close">Close</button>'
                    );
                    actions.querySelector('.swcp-preview-btn-close').addEventListener('click', function () {
                        hidePreviewPanel();
                    });
                }
                // If Claude had pre-emptively proposed apply_page_design and
                // is waiting on a tool_result, satisfy that loop so the
                // chat doesn't get stuck waiting for a confirmation that
                // already happened directly.
                var openApply = findPendingToolUse('apply_page_design');
                if (openApply) {
                    setToolStatus(openApply.mIdx, openApply.toolUseId, 'done', data);
                    var ackParts = [];
                    if (data.original_id) ackParts.push('original_id: ' + data.original_id);
                    if (data.view_url)    ackParts.push('view_url: '    + data.view_url);
                    messages.push({
                        role: 'user',
                        content: [{
                            type: 'tool_result',
                            tool_use_id: openApply.toolUseId,
                            content: 'Live ✓' + (ackParts.length ? '\n' + ackParts.join('\n') : ''),
                            is_error: false
                        }]
                    });
                    saveHistory();
                    renderMessages();
                    runChatTurn();
                } else {
                    // No pending tool — push a quiet assistant confirmation
                    // so the chat stays coherent without a Claude round-trip.
                    messages.push({
                        role: 'assistant',
                        content: [{ type: 'text', text: 'Live ✓ — your home page is now this design. Tell me if you want anything different.' }]
                    });
                    saveHistory();
                    renderMessages();
                }
                if (typeof fetchTimeline === 'function') fetchTimeline();
            }).catch(function (err) {
                applyBtn.disabled = false;
                applyBtn.textContent = 'Apply this design';
                if (discardBtn) discardBtn.disabled = false;
                if (titleEl) titleEl.textContent = 'Preview · your live page is unchanged';
                var errEl = document.createElement('div');
                errEl.className = 'swcp-preview-banner-error';
                var raw = (err && err.message) ? String(err.message) : 'Could not save. Try again.';
                // "Failed to fetch" is browser-speak — translate to something
                // the user can act on.
                if (/failed to fetch|networkerror/i.test(raw)) {
                    raw = 'Lost connection while saving. Refresh the page and try Apply again — your design is still here.';
                }
                errEl.textContent = raw;
                banner.appendChild(errEl);
                logApplyFailDiagnostic('final-failure', err, err && err.__code);
            });
        });
        overlay.querySelector('.swcp-preview-btn-discard').addEventListener('click', function () {
            var pid = result.preview_id;
            hidePreviewPanel();
            clearStashedPreview();
            renderPreviewRecovery();
            if (isMobile()) setCollapsed(false); // re-open the rail on phone
            // Hard-delete the preview draft so they don't pile up.
            if (pid) {
                fetch(config.restUrl + 'discard-preview', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.restNonce },
                    body: JSON.stringify({ preview_id: pid })
                }).catch(function () { /* best-effort */ });
            }
            send('I am going to think about it. Discard the preview for now.');
        });
        document.documentElement.classList.add('swcp-preview-open');
    }
    function hidePreviewPanel() {
        var el = document.getElementById('swcp-preview-overlay');
        if (el) el.remove();
        document.documentElement.classList.remove('swcp-preview-open');
        renderPreviewRecovery();
    }

    function cancelTool(mIdx, toolUseId) {
        if (pending) return;
        setToolStatus(mIdx, toolUseId, 'cancelled');
        // Send tool_result so Claude knows we declined.
        messages.push({
            role: 'user',
            content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'User declined this action.', is_error: false }]
        });
        saveHistory();
        renderMessages();
        runChatTurn();
    }

    /**
     * Pick mode — let the user click an element on the page to point at
     * it. We highlight the nearest Gutenberg block on hover, capture
     * relevant info on click (image src, heading text, button label, …),
     * and inject a "[Picked block]" context chip into the composer for
     * the next turn. Claude reads that line and turns it into a tool
     * locator (see system prompt).
     */
    var pickActive       = false;
    var pickHoverTarget  = null;
    var pickWasCollapsed = false;
    var pickedContext    = null; // { line: string, label: string }

    function togglePickMode() {
        if (pickActive) cancelPickMode();
        else            enterPickMode();
    }

    function enterPickMode() {
        pickActive = true;
        pickWasCollapsed = root.classList.contains('is-collapsed');
        if (!pickWasCollapsed) setCollapsed(true);
        document.documentElement.classList.add('sw-rail-pick-active');
        document.addEventListener('mousemove', onPickMouseMove, true);
        document.addEventListener('click',     onPickClick,     true);
        document.addEventListener('keydown',   onPickKey,       true);
        showPickHud();
    }

    function cancelPickMode() {
        pickActive = false;
        clearPickHover();
        document.documentElement.classList.remove('sw-rail-pick-active');
        document.removeEventListener('mousemove', onPickMouseMove, true);
        document.removeEventListener('click',     onPickClick,     true);
        document.removeEventListener('keydown',   onPickKey,       true);
        hidePickHud();
        if (!pickWasCollapsed) setCollapsed(false);
    }

    function onPickKey(e) {
        if (e.key === 'Escape') {
            e.preventDefault();
            cancelPickMode();
        }
    }

    function clearPickHover() {
        if (pickHoverTarget) {
            pickHoverTarget.classList.remove('sw-rail-pick-hover');
            pickHoverTarget = null;
        }
    }

    function onPickMouseMove(e) {
        if (!pickActive) return;
        var t = e.target;
        // Skip the rail itself + the HUD + previously-selected chip.
        if (!t || (t.closest && (t.closest('.sw-rail-root') || t.closest('.sw-rail-pick-hud')))) {
            clearPickHover();
            return;
        }
        var target = pickPickableElement(t);
        if (target === pickHoverTarget) return;
        clearPickHover();
        if (target) {
            pickHoverTarget = target;
            pickHoverTarget.classList.add('sw-rail-pick-hover');
        }
    }

    /**
     * Walk up from the clicked element to find the most useful "pickable"
     * target. Most-specific first: image > button > heading/p > nearest
     * container that paints its own background (so clicking a purple
     * section-bg picks the SECTION, not just the hover-near-text). Falls
     * back to the nearest wp-block-* wrapper.
     *
     * Joost on 2026-05-17: clicking on a section background did nothing
     * because the old filter ignored containers with no text leaf. A
     * background IS a thing the user wants to edit, so we now treat any
     * element with an actual painted bg as pickable.
     */
    function pickPickableElement(el) {
        if (!el || el.nodeType !== 1) return null;
        // Specific pick: image
        var img = el.closest && el.closest('img');
        if (img) return img;
        // Specific pick: button (link or <button>)
        var btn = el.closest && el.closest('a.wp-block-button__link, .wp-block-button a, button.wp-block-button__link');
        if (btn) return btn;
        // Specific pick: heading or paragraph or list (only when clicked
        // directly — if user clicked the surrounding background, the loop
        // below catches the bg container instead).
        var leaf = el.tagName;
        if (leaf && /^(H[1-6]|P|LI|BLOCKQUOTE)$/.test(leaf)) {
            return el;
        }
        // Walk up to find the nearest ancestor with its OWN painted
        // background. A transparent or unset bg doesn't count. This is
        // what "click the purple area" should resolve to.
        var node = el;
        var hops = 0;
        while (node && node !== document.body && hops < 8) {
            if (hasOwnBackground(node)) return node;
            node = node.parentElement;
            hops++;
        }
        // Generic: nearest Gutenberg block wrapper as last resort.
        var block = el.closest && el.closest('[class*="wp-block-"]');
        return block || null;
    }

    /**
     * True if the element paints its own background (a non-transparent
     * color, a gradient, or an image). Walks computed style — inline
     * styles, theme.json, and CSS custom-property fills all surface here.
     */
    function hasOwnBackground(el) {
        if (!el || el.nodeType !== 1) return false;
        var cs;
        try { cs = window.getComputedStyle(el); }
        catch (_) { return false; }
        if (!cs) return false;
        var bg = (cs.backgroundColor || '').trim();
        var img = (cs.backgroundImage || '').trim();
        var isTransparentColor = (
            !bg || bg === 'transparent' ||
            bg === 'rgba(0, 0, 0, 0)' || bg === 'rgba(0,0,0,0)'
        );
        var hasImageOrGradient = img && img !== 'none';
        return !isTransparentColor || hasImageOrGradient;
    }

    function onPickClick(e) {
        if (!pickActive) return;
        if (e.target && e.target.closest && e.target.closest('.sw-rail-root')) {
            // Clicks on the rail itself: ignore (do not consume).
            return;
        }
        e.preventDefault();
        e.stopPropagation();
        var t = pickPickableElement(e.target) || e.target;
        // Visual ack: 480ms pulse so the user sees the click registered
        // even on a calm background where nothing else changes.
        if (t && t.classList) {
            t.classList.add('sw-rail-pick-flash');
            setTimeout(function () {
                if (t && t.classList) t.classList.remove('sw-rail-pick-flash');
            }, 520);
        }
        var ctx = describePickedElement(t);
        if (ctx) {
            pickedContext = ctx;
            updatePickChip();
        }
        cancelPickMode();
    }

    /**
     * Build a context payload for the picked DOM element. The `line` is
     * what we'll prepend to the next user message so Claude has the
     * locator. The `label` is what we show in the composer chip.
     */
    function describePickedElement(el) {
        if (!el || el.nodeType !== 1) return null;
        var blockClass = '';
        var blockEl = el.closest('[class*="wp-block-"]');
        if (blockEl) {
            var m = (blockEl.className || '').match(/wp-block-[a-z0-9-]+/);
            if (m) blockClass = m[0];
        }
        if (el.tagName === 'IMG') {
            var src = el.getAttribute('src') || '';
            var alt = el.getAttribute('alt') || '';
            return {
                line: '[Picked block]: type=image src="' + src + '" alt="' + alt + '"' + (blockClass ? ' wrapper=' + blockClass : ''),
                label: 'Image: ' + src.split('/').pop().slice(0, 40),
            };
        }
        if (el.tagName === 'A' || el.tagName === 'BUTTON') {
            var label = (el.textContent || '').trim().slice(0, 80);
            var href = el.getAttribute('href') || '';
            return {
                line: '[Picked block]: type=button text="' + label + '"' + (href ? ' href="' + href + '"' : ''),
                label: 'Button: ' + label,
            };
        }
        if (/^H[1-6]$/.test(el.tagName)) {
            var ht = (el.textContent || '').trim().slice(0, 120);
            var lvl = el.tagName.charAt(1);
            return {
                line: '[Picked block]: type=heading level=' + lvl + ' text="' + ht + '"' + (blockClass ? ' wrapper=' + blockClass : ''),
                label: 'H' + lvl + ': ' + ht.slice(0, 40),
            };
        }
        if (el.tagName === 'P' || el.tagName === 'LI' || el.tagName === 'BLOCKQUOTE') {
            var pt = (el.textContent || '').trim().slice(0, 200);
            return {
                line: '[Picked block]: type=text text="' + pt + '"' + (blockClass ? ' wrapper=' + blockClass : ''),
                label: el.tagName.toLowerCase() + ': ' + pt.slice(0, 40),
            };
        }
        // Background-bearing container: surface the current bg so Claude
        // knows what to change FROM. Joost 2026-05-17: "click on the
        // purple section". The current colour belongs in the locator
        // hint or Claude has to guess.
        if (hasOwnBackground(el)) {
            var cs2; try { cs2 = window.getComputedStyle(el); } catch (_) { cs2 = null; }
            var bgColor = cs2 ? (cs2.backgroundColor || '').trim() : '';
            var bgImage = cs2 ? (cs2.backgroundImage || '').trim() : '';
            var bgPreview = (el.textContent || '').trim().slice(0, 60);
            var bgParts = ['type=background'];
            if (blockClass) bgParts.push('wrapper=' + blockClass);
            if (bgColor && bgColor !== 'transparent') bgParts.push('current_color="' + bgColor + '"');
            if (bgImage && bgImage !== 'none') {
                // Only the function name, not the full url — that's enough
                // for Claude to know it's a gradient/image.
                var fn = (bgImage.match(/^(linear-gradient|radial-gradient|conic-gradient|url)/) || [''])[0];
                if (fn) bgParts.push('current_image=' + fn);
            }
            if (bgPreview) bgParts.push('nearby_text="' + bgPreview.replace(/"/g, '\\"') + '"');
            var label = 'Background' + (blockClass ? ': ' + blockClass.replace(/^wp-block-/, '') : '');
            if (bgPreview) label += ' · ' + bgPreview.slice(0, 30);
            return {
                line: '[Picked block]: ' + bgParts.join(' '),
                label: label,
            };
        }
        // Generic block fallback: capture first inline text we can find.
        var preview = (el.textContent || '').trim().slice(0, 80);
        return {
            line: '[Picked block]: type=block name=' + (blockClass || el.tagName.toLowerCase()) + ' preview="' + preview + '"',
            label: (blockClass || el.tagName.toLowerCase()) + ': ' + preview.slice(0, 40),
        };
    }

    function showPickHud() {
        var hud = document.getElementById('sw-rail-pick-hud');
        if (hud) return;
        hud = document.createElement('div');
        hud.id = 'sw-rail-pick-hud';
        hud.className = 'sw-rail-pick-hud';
        hud.innerHTML = 'Click any element to pick it. <button type="button" class="sw-rail-pick-cancel">Cancel</button>';
        document.body.appendChild(hud);
        hud.querySelector('.sw-rail-pick-cancel').addEventListener('click', function (e) {
            e.preventDefault();
            cancelPickMode();
        });
    }

    function hidePickHud() {
        var hud = document.getElementById('sw-rail-pick-hud');
        if (hud) hud.remove();
    }

    /**
     * Render the currently-picked element as a small chip above the
     * composer textarea. Click the × to discard. For background-type
     * picks we also render a swatch row underneath — Joost 2026-05-17:
     * "klik op de achtergrond, kies meteen een kleur uit een palet". One
     * click on a swatch fires a chat turn that asks Co-pilot to change
     * the background to that hex — no manual hex entry needed.
     */
    function updatePickChip() {
        var slot = root.querySelector('.sw-rail-attachments');
        if (!slot) return;
        // Strip any prior pick chip + swatch row.
        var prior = slot.querySelector('.sw-rail-picked');
        if (prior) prior.remove();
        var priorSw = slot.querySelector('.sw-rail-pick-swatches');
        if (priorSw) priorSw.remove();
        if (!pickedContext) return;
        var chip = document.createElement('div');
        chip.className = 'sw-rail-attachment sw-rail-picked';
        chip.innerHTML = '<span class="sw-rail-attachment-label">' + escapeHtml(pickedContext.label) + '</span>' +
            '<button type="button" class="sw-rail-attachment-remove" data-pick-remove="1" aria-label="Discard pick">×</button>';
        slot.appendChild(chip);

        // Background pick → swatch quick-actions (instant colour change
        // via a single chat turn, no typing).
        var isBg = pickedContext.line && pickedContext.line.indexOf('type=background') !== -1;
        if (isBg) {
            var swatchRow = document.createElement('div');
            swatchRow.className = 'sw-rail-pick-swatches';
            swatchRow.innerHTML = renderBrandSwatchesHtml();
            slot.appendChild(swatchRow);
        }
    }

    /**
     * Brand palette + a few neutrals. Coral / dark / cream come from
     * sailwp brand.md; the rest are the most common section-bg picks
     * Joost reaches for ("dark blue with stars", soft beige, white).
     * Custom-hex chip at the end opens a native colour picker.
     */
    function renderBrandSwatchesHtml() {
        var swatches = [
            { hex: '#e8845c', label: 'Coral' },
            { hex: '#1a1a2e', label: 'Dark navy' },
            { hex: '#e8b87a', label: 'Warm cream' },
            { hex: '#ffffff', label: 'White' },
            { hex: '#1e3a5f', label: 'Deep blue' },
            { hex: '#2d4a3e', label: 'Forest green' },
            { hex: '#f5ecde', label: 'Beige' },
            { hex: '#3a3a3a', label: 'Charcoal' }
        ];
        var dots = swatches.map(function (s) {
            return '<button type="button" class="sw-rail-pick-swatch" ' +
                'data-hex="' + escapeHtml(s.hex) + '" ' +
                'title="' + escapeHtml(s.label + ' (' + s.hex + ')') + '" ' +
                'aria-label="' + escapeHtml('Change background to ' + s.label) + '" ' +
                'style="background:' + escapeHtml(s.hex) + ';">' +
                '</button>';
        }).join('');
        return (
            '<span class="sw-rail-pick-swatches-label">Quick colour:</span>' +
            dots +
            '<button type="button" class="sw-rail-pick-swatch sw-rail-pick-swatch-custom" ' +
                'title="Pick a custom colour" aria-label="Pick a custom colour">+</button>'
        );
    }

    /**
     * One-click bg-colour change. We fire it as a chat turn instead of a
     * direct REST POST so Claude can see the picked-element context, do
     * its locator resolution against the page, and the change shows up
     * in the regular tool chip + timeline + undo flow. ~2-3s round-trip.
     */
    function pickSwatchClicked(hex) {
        if (!hex) return;
        if (!pickedContext) return;
        // Pre-fill the composer with a clean instruction; the picked-
        // element line is appended automatically by the send pipeline.
        var msg = 'Change the background colour to ' + hex + '.';
        send(msg);
    }

    /**
     * "Custom" swatch: opens the browser-native colour picker (hidden
     * <input type="color">) so the user can pick any hex. On change we
     * route through pickSwatchClicked so the chat-turn path is shared.
     */
    function openCustomSwatchPicker() {
        if (!pickedContext) return;
        var input = document.createElement('input');
        input.type = 'color';
        // Try to seed with the element's current bg-colour so the picker
        // opens near the current value.
        var m = (pickedContext.line || '').match(/current_color="([^"]+)"/);
        if (m) {
            // <input type="color"> only accepts #rrggbb. Skip seeding for
            // rgb()/rgba() values — browser falls back to last-used.
            var hex = m[1];
            if (/^#[0-9a-f]{6}$/i.test(hex)) input.value = hex;
        }
        input.style.position = 'fixed';
        input.style.left = '-9999px';
        document.body.appendChild(input);
        input.addEventListener('change', function () {
            var val = input.value;
            try { document.body.removeChild(input); } catch (_) {}
            if (val) pickSwatchClicked(val);
        });
        input.addEventListener('blur', function () {
            // Safari sometimes never fires 'change' if user cancels.
            setTimeout(function () {
                if (input.parentNode) input.parentNode.removeChild(input);
            }, 200);
        });
        input.click();
    }

    function consumePickedContext() {
        var ctx = pickedContext;
        pickedContext = null;
        // Clear the chip too.
        var slot = root.querySelector('.sw-rail-attachments');
        if (slot) {
            var prior = slot.querySelector('.sw-rail-picked');
            if (prior) prior.remove();
        }
        return ctx;
    }

    /**
     * Page-reload scheduler. Tools opt in via `requires_refresh: true`.
     * The Undo button cancels the pending reload (so the rollback runs
     * before the user is bounced) and re-arms it after the rollback
     * tool resolves.
     */
    var pendingReloadTimer = null;
    function schedulePageReload(delayMs) {
        cancelScheduledReload();
        pendingReloadTimer = window.setTimeout(function () {
            pendingReloadTimer = null;
            try { window.location.reload(); } catch (e) { /* best-effort */ }
        }, delayMs);
    }
    function cancelScheduledReload() {
        if (pendingReloadTimer !== null) {
            window.clearTimeout(pendingReloadTimer);
            pendingReloadTimer = null;
        }
    }

    /**
     * Run the rollback tool packed into a previous tool_result's `undo`
     * descriptor. POSTs to /execute, marks the original chip as undone,
     * then reloads the page so the user sees the rollback. We do NOT
     * send a tool_result back to Claude — undo is a UI affordance for
     * the user, not a continuation of the chat plan.
     */
    function undoTool(mIdx, toolUseId) {
        if (pending) return;
        cancelScheduledReload();
        var m = messages[mIdx];
        if (!m || !m.ui || !m.ui.toolResults) return;
        var origResult = m.ui.toolResults[toolUseId];
        if (!origResult || !origResult.undo || !origResult.undo.tool || !origResult.undo.input) return;
        var spec = origResult.undo;

        // Visually flip the chip to "undoing" so the click feels acknowledged.
        m.ui.toolStatus[toolUseId] = 'running';
        renderMessages();
        pending = true;

        fetch(config.restUrl + 'execute', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.restNonce },
            body: JSON.stringify({ tool: spec.tool, input: spec.input })
        }).then(function (res) {
            return res.text().then(function (text) {
                var data = null; try { data = JSON.parse(text); } catch (e) {}
                return { ok: res.ok && data && data.ok, data: data };
            });
        }).then(function (out) {
            pending = false;
            if (out.ok) {
                // Mark the chip as reverted. Strip the undo so it can't be
                // double-clicked, and replace the message with a plain
                // "Reverted." line for the post-reload chat history.
                m.ui.toolResults[toolUseId] = Object.assign({}, origResult, {
                    message: 'Reverted that change.',
                    undo: null
                });
                m.ui.toolStatus[toolUseId] = 'done';
                saveHistory();
                renderMessages();
                schedulePageReload(800);
            } else {
                m.ui.toolStatus[toolUseId] = 'error';
                m.ui.toolResults[toolUseId] = Object.assign({}, origResult, {
                    message: 'Could not undo that change. Try editing the page directly.'
                });
                renderMessages();
            }
        }).catch(function () {
            pending = false;
            m.ui.toolStatus[toolUseId] = 'error';
            renderMessages();
        });
    }

    /**
     * Version timeline: a row of dots above the composer showing past
     * versions of the open page. Click a dot → confirm → restore. Built
     * on top of WP's native revisions (every wp_update_post via our
     * tools creates one). Hidden when no post is open or no revisions
     * exist yet.
     */
    var timelineVersions = [];
    var timelineLoading = false;

    function timelineEl() { return root.querySelector('.sw-rail-timeline'); }

    function fetchTimeline() {
        var ctx = (config.context || {});
        var postId = parseInt(ctx.postId, 10) || currentPostIdHint();
        var el = timelineEl();
        if (!el) return;
        if (!postId) { renderTimeline([]); return; }
        if (timelineLoading) return;
        timelineLoading = true;
        fetch(config.restUrl + 'page-versions?post_id=' + postId, {
            headers: { 'X-WP-Nonce': config.restNonce }
        }).then(function (res) { return res.json(); })
          .then(function (data) {
              timelineVersions = (data && Array.isArray(data.versions)) ? data.versions : [];
              renderTimeline(timelineVersions);
          })
          .catch(function () { renderTimeline([]); })
          .then(function () { timelineLoading = false; });
    }

    function renderTimeline(versions) {
        var el = timelineEl();
        if (!el) return;
        // Need at least 2 entries (the synthetic "current" + one real
        // revision) before the timeline is useful.
        if (!Array.isArray(versions) || versions.length < 2) {
            el.setAttribute('data-empty', 'true');
            el.innerHTML = '';
            return;
        }
        el.setAttribute('data-empty', 'false');
        var html = '<div class="sw-rail-timeline-track">';
        html += '<span class="sw-rail-timeline-label">Versions</span>';
        html += '<div class="sw-rail-timeline-dots">';
        versions.forEach(function (v, i) {
            var cls = 'sw-rail-timeline-dot';
            if (v.is_current) cls += ' is-current';
            var precise = v.ts ? formatVersionStamp(v.ts * 1000) : '';
            var tip = v.is_current
                ? ('Current' + (precise ? ' — ' + precise : ''))
                : (precise || v.age_label || '');
            html += '<button type="button" class="' + cls + '" ' +
                'data-rev-id="' + escapeHtml(String(v.revision_id || 0)) + '" ' +
                'data-age="' + escapeHtml(v.age_label || '') + '" ' +
                'aria-label="' + escapeHtml(v.label || ('Version ' + (i + 1))) + '" ' +
                'title="' + escapeHtml(tip) + '">' +
                '<span class="sw-rail-timeline-tooltip">' + escapeHtml(tip) + '</span>' +
            '</button>';
        });
        html += '</div></div>';
        el.innerHTML = html;
    }

    /**
     * Preview-first version restore (Patroon A — Google Docs / Figma).
     * Click on a dot opens a no-chrome iframe of that revision. User can
     * browse without committing. "Restore this version" then makes it live;
     * Cancel just closes the overlay. WordPress auto-creates a fresh
     * revision of the pre-restore live state, so nothing is lost.
     */
    function openVersionPreview(version) {
        if (!version || !version.preview_url || !version.revision_id) return;
        if (restoreInFlight || pending) return;
        hideVersionPreview(); // close any stale one
        var ctx = (config.context || {});
        var postId = parseInt(ctx.postId, 10) || currentPostIdHint();
        if (!postId) return;

        var stamp = version.ts ? formatVersionStamp(version.ts * 1000) : (version.age_label || '');
        var trackEl = timelineEl();
        if (trackEl) {
            // Clear any previous selection, mark this dot.
            var prev = trackEl.querySelector('.sw-rail-timeline-dot.is-previewing');
            if (prev) prev.classList.remove('is-previewing');
            var cur = trackEl.querySelector('.sw-rail-timeline-dot[data-rev-id="' + String(version.revision_id) + '"]');
            if (cur) cur.classList.add('is-previewing');
        }

        if (isMobile()) setCollapsed(true);
        var overlay = document.createElement('div');
        overlay.id = 'swcp-version-preview-overlay';
        overlay.className = 'swcp-preview-overlay-shell';
        overlay.innerHTML = (
            '<div class="swcp-preview-banner">' +
                '<div class="swcp-preview-banner-title">Previewing version from ' + escapeHtml(stamp) + ' · your live page is unchanged</div>' +
                '<div class="swcp-preview-banner-actions">' +
                    '<button type="button" class="swcp-preview-btn-discard">Cancel</button>' +
                    '<button type="button" class="swcp-preview-btn-apply">Restore this version</button>' +
                '</div>' +
            '</div>' +
            '<iframe class="swcp-preview-iframe" src="' + escapeHtml(version.preview_url) + '" loading="lazy" scrolling="yes"></iframe>'
        );
        document.body.appendChild(overlay);
        document.documentElement.classList.add('swcp-preview-open');

        var banner    = overlay.querySelector('.swcp-preview-banner');
        var titleEl   = banner.querySelector('.swcp-preview-banner-title');
        var applyBtn  = overlay.querySelector('.swcp-preview-btn-apply');
        var cancelBtn = overlay.querySelector('.swcp-preview-btn-discard');

        cancelBtn.addEventListener('click', function () {
            hideVersionPreview();
            if (isMobile()) setCollapsed(false);
        });

        applyBtn.addEventListener('click', function () {
            applyBtn.disabled = true;
            applyBtn.textContent = 'Restoring…';
            if (cancelBtn) cancelBtn.disabled = true;
            if (titleEl) titleEl.textContent = 'Restoring version from ' + stamp + '…';
            var prevErr = banner.querySelector('.swcp-preview-banner-error');
            if (prevErr) prevErr.remove();

            restoreInFlight = true;
            pending = true;
            if (trackEl) trackEl.setAttribute('data-restoring', 'true');

            fetch(config.restUrl + 'restore-version', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.restNonce },
                body: JSON.stringify({ post_id: postId, revision_id: parseInt(version.revision_id, 10) })
            }).then(function (res) {
                return res.text().then(function (text) {
                    var data = null; try { data = JSON.parse(text); } catch (e) {}
                    if (!res.ok) throw new Error((data && (data.message || data.code)) || 'restore failed');
                    return data;
                });
            }).then(function () {
                // Reload so the editor / front-end re-reads the post_content
                // we just rewrote. Leave the flags true so a panic double-
                // click can't fire a second restore before unload.
                if (titleEl) titleEl.textContent = '✓ Restored · reloading the page…';
                banner.classList.add('is-success');
                setTimeout(function () { window.location.reload(); }, 250);
            }).catch(function (err) {
                restoreInFlight = false;
                pending = false;
                if (trackEl) trackEl.removeAttribute('data-restoring');
                applyBtn.disabled = false;
                applyBtn.textContent = 'Restore this version';
                if (cancelBtn) cancelBtn.disabled = false;
                if (titleEl) titleEl.textContent = 'Previewing version from ' + stamp + ' · your live page is unchanged';
                var errEl = document.createElement('div');
                errEl.className = 'swcp-preview-banner-error';
                errEl.textContent = (err && err.message) ? err.message : 'Could not restore. Try again.';
                banner.appendChild(errEl);
            });
        });
    }

    function hideVersionPreview() {
        var el = document.getElementById('swcp-version-preview-overlay');
        if (el && el.parentNode) el.parentNode.removeChild(el);
        // Only drop the body class if NO other preview overlay is left.
        if (!document.getElementById('swcp-preview-overlay')) {
            document.documentElement.classList.remove('swcp-preview-open');
        }
        var trackEl = timelineEl();
        if (trackEl) {
            var prev = trackEl.querySelector('.sw-rail-timeline-dot.is-previewing');
            if (prev) prev.classList.remove('is-previewing');
        }
    }

    var restoreInFlight = false;
    function confirmAndRestore(revisionId) {
        // Race guard: a fast double-click on a dot must not queue two
        // confirms. We lock immediately, then release on completion or
        // when the user cancels the confirm dialog.
        if (restoreInFlight || pending) return;
        var ctx = (config.context || {});
        var postId = parseInt(ctx.postId, 10) || currentPostIdHint();
        if (!postId || !revisionId) return;
        var match = timelineVersions.filter(function (v) {
            return String(v.revision_id) === String(revisionId);
        })[0];
        var when = match ? (match.age_label || 'an earlier version') : 'an earlier version';

        restoreInFlight = true;
        if (!window.confirm('Restore the version from ' + when + '? Your current page will be saved as a new version first.')) {
            restoreInFlight = false;
            return;
        }
        pending = true;
        // Visual cue: flag the timeline track so all dots gain a "pending"
        // look while the restore runs. CSS handles the dimming.
        var trackEl = timelineEl();
        if (trackEl) trackEl.setAttribute('data-restoring', 'true');
        fetch(config.restUrl + 'restore-version', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.restNonce },
            body: JSON.stringify({ post_id: postId, revision_id: parseInt(revisionId, 10) })
        }).then(function (res) {
            return res.text().then(function (text) {
                var data = null; try { data = JSON.parse(text); } catch (e) {}
                if (!res.ok) throw new Error((data && (data.message || data.code)) || 'restore failed');
                return data;
            });
        }).then(function () {
            // Hard reload — the page editor needs to re-read content from
            // the database (it doesn't watch post_content meta), and the
            // front-end needs to render the restored markup. We leave
            // restoreInFlight/pending true until the unload kicks in so a
            // panic double-click cannot fire another request.
            setTimeout(function () { window.location.reload(); }, 150);
        }).catch(function (err) {
            restoreInFlight = false;
            pending = false;
            if (trackEl) trackEl.removeAttribute('data-restoring');
            messages.push({ role: 'error', content: 'Could not restore that version: ' + (err && err.message ? err.message : 'request failed') });
            renderMessages();
        });
    }

    /**
     * Best-effort current-post detection. Used to resolve "current" in tool
     * inputs without an extra round-trip. Falls back to 0.
     */
    function currentPostIdHint() {
        try {
            // PRIMARY: server-side ctx.postId, set by class-rail.php via
            // wp_localize_script. PHP knows what page this is even on
            // designed-page fullpage templates (which strip the WP body
            // class), so this is authoritative. v0.24.7 (Joost 2026-05-17):
            // homepage with a designed front-page kept hitting "cannot
            // find that page" because the fullpage template renders body
            // class "swcp-fullpage" — no page-id-X — so the body-class
            // fallback below returned 0.
            if (config && config.context && config.context.postId) {
                var pid = parseInt(config.context.postId, 10);
                if (pid > 0) return pid;
            }
            // Gutenberg / block editor
            if (window.wp && window.wp.data && window.wp.data.select) {
                var sel = window.wp.data.select('core/editor');
                if (sel && typeof sel.getCurrentPostId === 'function') {
                    var id = sel.getCurrentPostId();
                    if (id) return id;
                }
            }
            // URL params: ?post=, ?p=, ?postId= (site editor)
            var m = (location.search || '').match(/[?&](?:post|p|postId)=(\d+)/);
            if (m) return parseInt(m[1], 10);
            // Front-end body class: <body class="postid-12 page-id-12">
            var bc = (document.body && document.body.className) || '';
            var bm = bc.match(/\bpostid-(\d+)\b/) || bc.match(/\bpage-id-(\d+)\b/);
            if (bm) return parseInt(bm[1], 10);
        } catch (e) { /* ignore */ }
        return 0;
    }

    function newChat() {
        // Flush any pending sync of the about-to-be-abandoned chat so we
        // don't write a half-message version on top of the cloud copy.
        if (chatSyncTimer) { clearTimeout(chatSyncTimer); chatSyncTimer = null; }
        messages = [];
        pending = false;
        currentChatId = null;
        persistCurrentChatId(null);
        clearHistory();
        renderMessages();
        focusComposer();
        hideHistoryPanel();
    }

    function updateMicState() {
        var btn = root.querySelector('.sw-rail-mic');
        if (!btn) return;
        btn.classList.toggle('is-listening', listening);
        btn.setAttribute('aria-label', listening ? 'Tap to stop' : 'Tap to talk');
        btn.setAttribute('title',       listening ? 'Tap to stop' : 'Tap to talk');
    }

    var iconImage = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
    var iconDoc   = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
    var iconPdf   = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h1.5a1.5 1.5 0 0 1 0 3H8z"/><path d="M14 13v3h2"/></svg>';

    function attachmentIcon(kind) {
        if (kind === 'image') return iconImage;
        if (kind === 'pdf')   return iconPdf;
        return iconDoc; // word, text, anything else
    }

    function renderAttachments() {
        var area = root.querySelector('.sw-rail-attachments');
        if (!area) return;
        if (!pendingAttachments.length) { area.innerHTML = ''; area.style.display = 'none'; return; }
        area.style.display = 'flex';
        area.innerHTML = pendingAttachments.map(function (a, i) {
            return (
                '<div class="sw-rail-attachment">' +
                    '<span class="sw-rail-attachment-icon">' + attachmentIcon(a.kind) + '</span>' +
                    '<span class="sw-rail-attachment-name">' + escapeHtml(a.name) + '</span>' +
                    '<button type="button" class="sw-rail-attachment-remove" data-idx="' + i + '" aria-label="Remove">×</button>' +
                '</div>'
            );
        }).join('');
    }

    /**
     * Render a small "View last preview" chip when we have a stashed
     * preview for the current page and the overlay isn't already open.
     * Lets the user recover a design after a failed Apply, an accidental
     * Discard from the chat, or a page reload that closed the iframe.
     */
    function renderPreviewRecovery() {
        var area = root.querySelector('.sw-rail-preview-recovery');
        if (!area) return;
        var stash = getStashedPreview();
        var overlayOpen = !!document.getElementById('swcp-preview-overlay');
        var currentPid = currentPostIdHint();
        var matchesPage = !stash || !stash.page_id || !currentPid || String(stash.page_id) === String(currentPid);
        if (!stash || overlayOpen || !matchesPage) {
            area.innerHTML = '';
            area.setAttribute('data-empty', 'true');
            area.style.display = 'none';
            return;
        }
        area.setAttribute('data-empty', 'false');
        area.style.display = 'flex';
        area.innerHTML = (
            '<button type="button" class="sw-rail-preview-recovery-chip" title="Re-open the last design preview">' +
                '<span class="sw-rail-preview-recovery-icon">' + iconSailwp + '</span>' +
                '<span class="sw-rail-preview-recovery-label">View last preview</span>' +
                '<span class="sw-rail-preview-recovery-dismiss" data-action="dismiss" aria-label="Dismiss" title="Dismiss">×</span>' +
            '</button>'
        );
    }

    function classifyFile(file) {
        var n = (file.name || '').toLowerCase();
        var t = (file.type || '').toLowerCase();
        if (/^image\/(png|jpe?g|gif|webp)$/.test(t) || /\.(png|jpe?g|gif|webp)$/.test(n)) return 'image';
        if (t === 'application/pdf' || /\.pdf$/.test(n)) return 'pdf';
        if (/wordprocessingml|msword/.test(t) || /\.docx?$/.test(n)) return 'word';
        if (t === 'text/plain' || t === 'text/markdown' || /\.(txt|md|markdown)$/.test(n)) return 'text';
        return null;
    }

    function readAsText(file) {
        return new Promise(function (resolve, reject) {
            var fr = new FileReader();
            fr.onload = function () { resolve(String(fr.result || '')); };
            fr.onerror = function () { reject(fr.error); };
            fr.readAsText(file);
        });
    }
    function readAsDataUrl(file) {
        return new Promise(function (resolve, reject) {
            var fr = new FileReader();
            fr.onload = function () { resolve(String(fr.result || '')); };
            fr.onerror = function () { reject(fr.error); };
            fr.readAsDataURL(file);
        });
    }

    function pushFlashError(msg) {
        messages.push({ role: 'error', content: msg });
        renderMessages();
    }

    function uploadImageToMedia(name, dataUrl) {
        return fetch(config.restUrl + 'upload-image', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-WP-Nonce': config.restNonce
            },
            body: JSON.stringify({ name: name, dataUrl: dataUrl })
        }).then(function (res) {
            return res.json().then(function (data) {
                if (!res.ok || !data.ok || !data.url) {
                    throw new Error((data && (data.message || data.code)) || ('HTTP ' + res.status));
                }
                return data.url;
            });
        });
    }

    /**
     * Walk the conversation history and collect every image URL that has
     * been uploaded so far. Used to auto-inject additional_image_urls when
     * the model calls design_page_preview, so uploaded photos actually end
     * up in the design output.
     */
    function collectUploadedImageUrls() {
        var urls = [];
        messages.forEach(function (m) {
            if (m.role !== 'user' || !Array.isArray(m.apiAttachments)) return;
            m.apiAttachments.forEach(function (a) {
                if (a.kind === 'image' && a.url && urls.indexOf(a.url) === -1) {
                    urls.push(a.url);
                }
            });
        });
        return urls;
    }

    function handleFiles(fileList) {
        if (!fileList || !fileList.length) return;
        var promises = [];
        for (var i = 0; i < fileList.length; i++) {
            var file = fileList[i];
            if (pendingAttachments.length >= ATTACH_MAX) {
                pushFlashError('Up to ' + ATTACH_MAX + ' attachments per message.');
                break;
            }
            if (file.size > ATTACH_MAX_BYTES) {
                pushFlashError('File "' + file.name + '" is too large (over 10 MB).');
                continue;
            }
            var kind = classifyFile(file);
            if (!kind) {
                pushFlashError('Unsupported file type: ' + (file.name || ''));
                continue;
            }
            (function (file, kind) {
                if (kind === 'text') {
                    promises.push(readAsText(file).then(function (text) {
                        pendingAttachments.push({ name: file.name, kind: 'text', text: text.slice(0, 200000) });
                    }));
                } else if (kind === 'image' || kind === 'pdf') {
                    promises.push(readAsDataUrl(file).then(function (dataUrl) {
                        var m = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
                        if (!m) throw new Error('Could not read ' + file.name);
                        var mediaType = m[1];
                        var b64       = m[2];
                        var block;
                        if (kind === 'image') {
                            if (!/^image\/(jpeg|png|gif|webp)$/.test(mediaType)) {
                                throw new Error('Unsupported image format');
                            }
                            block = { type: 'image', source: { type: 'base64', media_type: mediaType, data: b64 } };
                        } else {
                            block = { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: b64 } };
                        }
                        var attachment = { name: file.name, kind: kind, block: block };
                        pendingAttachments.push(attachment);
                        // Background-upload images to the WP media library so
                        // the design tool can reference them as a real URL.
                        if (kind === 'image') {
                            attachment.uploadPromise = uploadImageToMedia(file.name, dataUrl)
                                .then(function (url) { attachment.url = url; renderAttachments(); })
                                .catch(function (err) { console.warn('SailWP Co-pilot: image upload failed', err); });
                        }
                    }));
                } else {
                    // .doc/.docx — server extracts via mammoth, returns text
                    promises.push(readAsDataUrl(file).then(function (dataUrl) {
                        pendingAttachments.push({ name: file.name, kind: kind, dataUrl: dataUrl });
                    }));
                }
            })(file, kind);
        }
        Promise.all(promises).then(function () { renderAttachments(); }).catch(function (err) {
            pushFlashError('Could not read file: ' + (err && err.message ? err.message : 'unknown'));
        });
    }

    function removeAttachment(idx) {
        if (idx >= 0 && idx < pendingAttachments.length) {
            pendingAttachments.splice(idx, 1);
            renderAttachments();
        }
    }

    function toggleVoice() {
        if (!voiceSupported) return;
        if (listening && recognition) {
            try { recognition.stop(); } catch (e) {}
            return;
        }
        if (!recognition) {
            recognition = new SpeechRec();
            recognition.continuous     = true;
            recognition.interimResults = true;
            // Prefer the user's BROWSER language (their mother tongue)
            // over the WP site language. Falls back to the site locale and
            // then en-US.
            recognition.lang = (navigator.language || config.siteLocale || document.documentElement.lang || 'en-US');

            recognition.onresult = function (event) {
                var ta = root.querySelector('.sw-rail-composer textarea');
                if (!ta) return;
                var interim = '', finalText = '';
                for (var i = event.resultIndex; i < event.results.length; i++) {
                    var r = event.results[i];
                    if (r.isFinal) finalText += r[0].transcript;
                    else interim += r[0].transcript;
                }
                if (finalText) {
                    voiceBaseText = (voiceBaseText + ' ' + finalText).trim();
                }
                ta.value = (voiceBaseText + (interim ? ' ' + interim : '')).trim();
                ta.dispatchEvent(new Event('input', { bubbles: true })); // trigger auto-grow
            };
            recognition.onend = function () {
                listening = false;
                updateMicState();
            };
            recognition.onerror = function (e) {
                listening = false;
                updateMicState();
                // Friendly user-facing message for the most common case:
                // mic permission denied (especially on iOS Safari).
                var err = e && e.error;
                if (err === 'not-allowed' || err === 'service-not-allowed') {
                    pushFlashError('Microphone is blocked. Tap the lock icon in your browser address bar and allow the microphone, then try again.');
                } else if (err === 'no-speech') {
                    pushFlashError('I did not catch any speech. Tap the mic again and try once more.');
                }
            };
        }
        var ta = root.querySelector('.sw-rail-composer textarea');
        voiceBaseText = ta ? ta.value : '';
        // While listening, any user-initiated edit (typing, paste, delete)
        // must re-anchor the speech base so the next onresult appends to
        // the corrected text instead of stomping it back to whatever the
        // recognizer last "heard". `isTrusted` separates real user input
        // from the synthetic `input` event that onresult itself dispatches.
        if (ta && !ta.dataset.swRailVoiceBaselineBound) {
            ta.addEventListener('input', function (ev) {
                if (!listening) return;
                if (ev.isTrusted === false) return;
                voiceBaseText = ta.value;
            });
            ta.dataset.swRailVoiceBaselineBound = '1';
        }
        try {
            recognition.start();
            listening = true;
            updateMicState();
            if (ta) ta.focus();
        } catch (e) {
            listening = false;
            updateMicState();
        }
    }

    // === Async design-job poller ===
    // design_page_preview returns a pending job_id (the proxy runs Opus in
    // the background to dodge upstream timeouts on customer sites). We poll
    // the proxy's CORS-open status endpoint every 3s, and once the design
    // is done call our /finalize-design REST route to create the preview
    // draft. The action chip stays in "running" state throughout.
    // ------------------------------------------------------------------
    // Design progress bar — top-of-viewport bar with a tiny SVG sailboat
    // that drifts to the right as the design job ticks. Estimated total
    // is 75s (typical Opus design). We cap visible progress at 95% until
    // the actual "done" event fires, then jump to 100% and fade out.
    // Joost on 2026-05-16: wanted a "zeilbootje wat over een lijntje
    // heen vaart" so users feel progression instead of staring at chat.
    // ------------------------------------------------------------------

    var designProgressEl = null;
    var EST_DESIGN_MS = 75 * 1000;

    function ensureDesignProgressEl() {
        if (designProgressEl) return designProgressEl;
        var el = document.createElement('div');
        el.className = 'swcp-design-progress';
        el.innerHTML = (
            '<div class="swcp-design-progress-label">Designing your page</div>' +
            '<div class="swcp-design-progress-track">' +
                '<div class="swcp-design-progress-fill"></div>' +
                '<div class="swcp-design-progress-boat" aria-hidden="true">' +
                    '<svg width="28" height="22" viewBox="0 0 28 22" fill="none">' +
                        // Sail
                        '<path d="M14 2 L14 14 L24 14 Z" fill="#e8845c"/>' +
                        // Mast
                        '<line x1="14" y1="2" x2="14" y2="14" stroke="#1a1a2e" stroke-width="1.2" stroke-linecap="round"/>' +
                        // Hull
                        '<path d="M6 14 L22 14 L19 19 L9 19 Z" fill="#1a1a2e"/>' +
                        // Water ripple
                        '<path d="M2 20 Q5 19 8 20 T14 20 T20 20 T26 20" stroke="rgba(56,132,255,0.7)" stroke-width="1.1" fill="none" stroke-linecap="round"/>' +
                    '</svg>' +
                '</div>' +
            '</div>'
        );
        document.body.appendChild(el);
        designProgressEl = el;
        return el;
    }

    function showDesignProgress(pageLabel) {
        var el = ensureDesignProgressEl();
        var label = el.querySelector('.swcp-design-progress-label');
        if (label) label.textContent = 'Designing ' + (pageLabel || 'your page') + '…';
        el.classList.remove('is-done', 'is-hidden');
        el.classList.add('is-visible');
        updateDesignProgress(0);
    }

    function updateDesignProgress(elapsedMs) {
        if (!designProgressEl) return;
        var pct = Math.min(0.95, (elapsedMs || 0) / EST_DESIGN_MS);
        var fill = designProgressEl.querySelector('.swcp-design-progress-fill');
        var boat = designProgressEl.querySelector('.swcp-design-progress-boat');
        if (fill) fill.style.transform = 'scaleX(' + pct + ')';
        if (boat) boat.style.left = (pct * 100) + '%';
    }

    function finishDesignProgress() {
        if (!designProgressEl) return;
        var fill = designProgressEl.querySelector('.swcp-design-progress-fill');
        var boat = designProgressEl.querySelector('.swcp-design-progress-boat');
        if (fill) fill.style.transform = 'scaleX(1)';
        if (boat) boat.style.left = '100%';
        designProgressEl.classList.add('is-done');
        setTimeout(hideDesignProgress, 700);
    }

    function hideDesignProgress() {
        if (!designProgressEl) return;
        designProgressEl.classList.remove('is-visible');
        designProgressEl.classList.add('is-hidden');
    }

    function pollDesignJob(initial, mIdx, toolUseId, toolUse) {
        var jobId      = initial.job_id;
        var originalId = initial.original_id;
        // Same-origin status endpoint (server-side proxied to ai.sailwp.com).
        // Avoids CORS preflights and gives the rail one robust path: every
        // tick is a normal WP REST call with the standard nonce.
        var statusUrl  = config.restUrl + 'design-status?job_id=' + encodeURIComponent(jobId);
        var pollStart  = Date.now();
        var MAX_POLL_MS = 5 * 60 * 1000;
        var INTERVAL_MS = 3000;
        var MAX_CONSECUTIVE_ERRORS = 5;
        var consecutiveErrors = 0;
        var aborted = false;

        showDesignProgress(initial.page_title || 'your page');

        function fail(message) {
            if (aborted) return;
            aborted = true;
            hideDesignProgress();
            var errResult = { ok: false, message: message };
            setToolStatus(mIdx, toolUseId, 'error', errResult);
            messages.push({
                role: 'user',
                content: [{ type: 'tool_result', tool_use_id: toolUseId, content: message, is_error: true }]
            });
            saveHistory();
            renderMessages();
            runChatTurn();
        }

        function done(finalResult) {
            if (aborted) return;
            aborted = true;
            finishDesignProgress();
            handleToolResult(finalResult, mIdx, toolUseId, toolUse);
        }

        function tick() {
            if (aborted) return;
            if (Date.now() - pollStart > MAX_POLL_MS) {
                fail('Design is taking too long. Try again with a simpler prompt.');
                return;
            }
            fetch(statusUrl, {
                headers: { 'X-WP-Nonce': config.restNonce },
                credentials: 'same-origin',
            }).then(function (r) {
                return r.text().then(function (text) {
                    var data = null;
                    try { data = JSON.parse(text); } catch (_) {}
                    return { res: r, data: data };
                });
            }).then(function (envelope) {
                var r = envelope.res, s = envelope.data;
                // job_not_found = give up cleanly. Without this the rail
                // would spam the network forever after a server restart.
                if (r.status === 404 || (s && s.code === 'rest_not_found') || (s && s.error === 'job_not_found')) {
                    fail('The design job expired (server may have restarted). Try the prompt again.');
                    return;
                }
                if (!r.ok || !s || s.ok === false) {
                    consecutiveErrors++;
                    if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
                        fail('Lost connection to the design service. Try again in a moment.');
                        return;
                    }
                    setTimeout(tick, INTERVAL_MS);
                    return;
                }
                consecutiveErrors = 0;
                if (s.status === 'pending') {
                    updateDesignProgress(s.elapsed_ms || (Date.now() - pollStart));
                    setTimeout(tick, INTERVAL_MS);
                    return;
                }
                if (s.status === 'error') {
                    fail((s.error || 'Design failed.').toString());
                    return;
                }
                // done — pull the rendered HTML into a preview post via our REST.
                fetch(config.restUrl + 'finalize-design', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-WP-Nonce': config.restNonce,
                    },
                    credentials: 'same-origin',
                    body: JSON.stringify({ job_id: jobId, original_id: originalId }),
                }).then(function (r) { return r.json(); }).then(function (finalResult) {
                    if (!finalResult || finalResult.ok !== true) {
                        fail((finalResult && finalResult.message) || 'Could not save the preview.');
                        return;
                    }
                    done(finalResult);
                }).catch(function (err) {
                    fail('Could not save the preview: ' + (err && err.message ? err.message : 'unknown'));
                });
            }).catch(function () {
                consecutiveErrors++;
                if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
                    fail('Lost connection to the design service. Try again in a moment.');
                    return;
                }
                setTimeout(tick, INTERVAL_MS);
            });
        }
        setTimeout(tick, INTERVAL_MS);
    }

    // Shared finalisation path used by both the sync tool-result flow and
    // the async poller. Mirrors what used to live inline in the .then()
    // handler on /execute.
    function handleToolResult(result, mIdx, toolUseId, toolUse) {
        var ok = !!result.ok;
        setToolStatus(mIdx, toolUseId, ok ? 'done' : 'error', result);
        var resultText = result.message || (ok ? 'Done.' : 'Failed.');
        if (ok || result.sparse || result.limit_reached) {
            var structured = [];
            ['preview_id', 'preview_url', 'original_id', 'view_url', 'edit_url', 'page_id', 'email', 'page_title', 'sparse', 'body_chars', 'image_count', 'upgrade_url', 'limit_reached', 'created', 'post_id', 'target_url', 'target_label'].forEach(function (k) {
                if (result[k] !== undefined && result[k] !== null && result[k] !== '') {
                    structured.push(k + ': ' + result[k]);
                }
            });
            if (Array.isArray(result.headings_found) && result.headings_found.length) {
                structured.push('headings_found: ' + JSON.stringify(result.headings_found));
            }
            if (structured.length) resultText += '\n\n' + structured.join('\n');
        }
        messages.push({
            role: 'user',
            content: [{ type: 'tool_result', tool_use_id: toolUseId, content: resultText, is_error: !ok }]
        });
        saveHistory();
        renderMessages();
        if (ok && (toolUse.name === 'design_page_preview' || toolUse.name === 'iterate_page_design' || toolUse.name === 'redesign_page') && result.preview_url) {
            stashPreview(result);
            showPreviewPanel(result);
        }
        if (ok) fetchTimeline();
        runChatTurn();
    }

    // === In-chat Stripe upgrade modal ===
    // Triggered from the upgrade card rendered in renderActionChip when a
    // tool_result comes back with limit_reached=true. Mounts Stripe's
    // Embedded Checkout right next to the chat so the user never leaves.
    var stripeJsPromise = null;
    function loadStripeJs() {
        if (window.Stripe) return Promise.resolve(window.Stripe);
        if (stripeJsPromise) return stripeJsPromise;
        stripeJsPromise = new Promise(function (resolve, reject) {
            var s = document.createElement('script');
            s.src = 'https://js.stripe.com/v3/';
            s.async = true;
            s.onload = function () { resolve(window.Stripe); };
            s.onerror = function () { stripeJsPromise = null; reject(new Error('Could not load Stripe.js')); };
            document.head.appendChild(s);
        });
        return stripeJsPromise;
    }

    var activeCheckout = null;
    var upgradeStatusPoll = null;
    function closeUpgradeModal() {
        var overlay = document.querySelector('.sw-rail-upgrade-overlay');
        if (overlay) overlay.remove();
        if (activeCheckout) {
            try { activeCheckout.destroy(); } catch (_) {}
            activeCheckout = null;
        }
        if (upgradeStatusPoll) { clearInterval(upgradeStatusPoll); upgradeStatusPoll = null; }
    }

    function openUpgradeModal(plan, fallbackUrl) {
        closeUpgradeModal();

        var overlay = document.createElement('div');
        overlay.className = 'sw-rail-upgrade-overlay';
        overlay.innerHTML =
            '<div class="sw-rail-upgrade-modal">' +
                '<button type="button" class="sw-rail-upgrade-close" aria-label="Close">×</button>' +
                '<div class="sw-rail-upgrade-header">' +
                    '<h3>Upgrade to Co-pilot Pro</h3>' +
                    '<p>Unlimited image generation and custom page designs. Cancel anytime.</p>' +
                '</div>' +
                '<div class="sw-rail-upgrade-checkout" id="sw-rail-upgrade-checkout">' +
                    '<div class="sw-rail-upgrade-loading">Loading checkout…</div>' +
                '</div>' +
            '</div>';
        document.body.appendChild(overlay);

        overlay.addEventListener('click', function (e) {
            if (e.target === overlay || (e.target.closest && e.target.closest('.sw-rail-upgrade-close'))) {
                closeUpgradeModal();
            }
        });

        var licenseKey = (config && (config.licenseKey || config.license_key)) || '';
        // Fetch the embedded session client_secret + publishable_key.
        fetch(config.aiProxyUrl.replace(/\/+$/, '') + '/api/v1/copilot/upgrade-session', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-License-Key': licenseKey,
            },
            body: JSON.stringify({ plan: plan || 'monthly', license_key: licenseKey }),
        }).then(function (r) { return r.json(); }).then(function (data) {
            if (!data || !data.ok) {
                throw new Error((data && (data.message || data.error)) || 'Could not start checkout');
            }
            if (!data.publishable_key) {
                // No publishable key configured server-side — fall back to the
                // hosted-checkout redirect flow. Open in a new tab so the chat
                // stays put. The user can come back to it.
                closeUpgradeModal();
                if (fallbackUrl) window.open(fallbackUrl, '_blank', 'noopener');
                else pushFlashError('Inline checkout is not configured yet. Ask Joost to set STRIPE_PUBLISHABLE_KEY on the proxy.');
                return;
            }
            return loadStripeJs().then(function (StripeFn) {
                var stripe = StripeFn(data.publishable_key);
                return stripe.initEmbeddedCheckout({
                    clientSecret: data.client_secret,
                    onComplete: function () {
                        // Stripe says checkout is done. We don't trust the
                        // client — poll our own status endpoint that re-fetches
                        // the session from Stripe and only reports paid=true
                        // once Stripe confirms payment.
                        var match = data.client_secret.match(/(cs_(?:live|test)_[A-Za-z0-9]+)/);
                        var sessionId = match ? match[1] : null;
                        if (!sessionId) { closeUpgradeModal(); return; }
                        var pollUrl = config.aiProxyUrl.replace(/\/+$/, '') + '/api/v1/copilot/upgrade-status?session_id=' + encodeURIComponent(sessionId);
                        var tries = 0;
                        upgradeStatusPoll = setInterval(function () {
                            tries++;
                            fetch(pollUrl).then(function (r) { return r.json(); }).then(function (s) {
                                if (s && s.paid) {
                                    closeUpgradeModal();
                                    pushFlashOk('You are on Co-pilot Pro. ✨ Try your request again.');
                                } else if (tries > 30) {
                                    clearInterval(upgradeStatusPoll); upgradeStatusPoll = null;
                                }
                            }).catch(function () { /* keep polling */ });
                        }, 1500);
                    },
                });
            }).then(function (checkout) {
                activeCheckout = checkout;
                var mountEl = overlay.querySelector('#sw-rail-upgrade-checkout');
                if (mountEl) mountEl.innerHTML = '';
                checkout.mount('#sw-rail-upgrade-checkout');
            });
        }).catch(function (err) {
            var msg = (err && err.message) || 'Could not start checkout';
            var mountEl = overlay.querySelector('#sw-rail-upgrade-checkout');
            if (mountEl) mountEl.innerHTML = '<div class="sw-rail-upgrade-error">' + msg + '</div>';
        });
    }
    function pushFlashOk(msg) {
        // Reuse the existing flash machinery if present; fall back to a chat
        // assistant message so the success is visible regardless.
        if (typeof pushFlashMessage === 'function') {
            pushFlashMessage(msg, 'ok');
        } else {
            messages.push({ role: 'assistant', content: [{ type: 'text', text: msg }] });
            saveHistory();
            renderMessages();
        }
    }

    function attach() {
        root.addEventListener('click', function (e) {
            var t = e.target.closest('button');
            if (!t) return;
            if (t.classList.contains('sw-rail-toggle'))      { setCollapsed(false); return; }
            if (t.classList.contains('sw-rail-close'))       { setCollapsed(true); return; }
            if (t.classList.contains('sw-rail-new'))         { newChat(); return; }
            if (t.classList.contains('sw-rail-history'))     { toggleHistoryPanel(); return; }
            if (t.classList.contains('sw-rail-history-open')) {
                var id = t.getAttribute('data-chat-id');
                if (id) loadChatById(id).then(function () { hideHistoryPanel(); });
                return;
            }
            if (t.classList.contains('sw-rail-history-del')) {
                var did = t.getAttribute('data-chat-id');
                if (!did) return;
                if (!window.confirm('Delete this chat? This cannot be undone.')) return;
                deleteChatById(did).then(function (ok) {
                    if (!ok) return;
                    // If we just deleted the active chat, reset to a fresh one.
                    if (did === currentChatId) {
                        currentChatId = null;
                        persistCurrentChatId(null);
                        messages = [];
                        try { localStorage.removeItem(HISTORY_KEY); } catch (e) {}
                        renderMessages();
                    }
                    fetchChatHistory().then(renderHistoryPanel);
                });
                return;
            }
            if (t.classList.contains('sw-rail-suggestion'))  {
                // Immediate visual ack — Joost on 2026-05-16: "ik klikte één
                // keer, werkte niet, moest dubbel klikken." Most likely the
                // first click registered fine but the rail rendered slowly
                // enough that he couldn't tell, so he clicked again. Marking
                // the chip as busy + disabling siblings the instant the click
                // fires means the user sees the click landed.
                if (!t.disabled) {
                    t.classList.add('is-busy');
                    var section = t.closest('.sw-rail-suggestion-section');
                    if (section) {
                        section.querySelectorAll('.sw-rail-suggestion').forEach(function (s) { s.disabled = true; });
                    }
                    send(t.getAttribute('data-text'));
                }
                return;
            }
            if (t.classList.contains('sw-rail-upgrade-plan')) {
                var plan = t.getAttribute('data-plan') || 'monthly';
                // Pull fallback hosted URL out of the related toolResult so we
                // have something if STRIPE_PUBLISHABLE_KEY isn't set yet.
                var mIdx = parseInt(t.getAttribute('data-msg'), 10);
                var toolId = t.getAttribute('data-tool-id');
                var fallback = '';
                try {
                    var m = messages[mIdx];
                    var r = m && m.ui && m.ui.toolResults && m.ui.toolResults[toolId];
                    fallback = r && r.upgrade_url ? (r.upgrade_url + (r.upgrade_url.indexOf('?') >= 0 ? '&' : '?') + 'plan=' + plan) : '';
                } catch (_) {}
                // Chat-level paywall card (no tool_id) — fall back to the
                // proxy upgrade endpoint with the license attached so the
                // user still has a working path if inline Stripe is down.
                if (!fallback && config && config.aiProxyUrl && config.licenseKey) {
                    fallback = config.aiProxyUrl.replace(/\/+$/, '') +
                        '/api/v1/upgrade?license=' + encodeURIComponent(config.licenseKey) +
                        '&plan=' + plan;
                }
                openUpgradeModal(plan, fallback);
                return;
            }
            if (t.classList.contains('sw-rail-chip-confirm')){
                // Immediate visual ack — Joost 2026-05-17: "ik moet drie
                // keer klikken voordat dit werkt". Root cause was that
                // confirmTool returns silently when pending=true, and the
                // user saw nothing on first click. We now disable the
                // button + relabel "Working…" the moment a click lands,
                // BEFORE we check pending. If we are actually pending,
                // confirmTool will no-op but the button visibly responded.
                if (!t.disabled) {
                    t.disabled = true;
                    t.textContent = 'Working…';
                    var siblings = t.closest('.sw-rail-chip-actions');
                    if (siblings) siblings.querySelectorAll('button').forEach(function (b) { b.disabled = true; });
                }
                confirmTool(parseInt(t.getAttribute('data-msg'), 10), t.getAttribute('data-tool-id'));
                return;
            }
            if (t.classList.contains('sw-rail-chip-cancel')) {
                if (!t.disabled) {
                    t.disabled = true;
                    var sib2 = t.closest('.sw-rail-chip-actions');
                    if (sib2) sib2.querySelectorAll('button').forEach(function (b) { b.disabled = true; });
                }
                cancelTool(parseInt(t.getAttribute('data-msg'), 10), t.getAttribute('data-tool-id'));
                return;
            }
            if (t.classList.contains('sw-rail-timeline-dot')) {
                var revId = t.getAttribute('data-rev-id');
                // Synthetic "Current" dot (revId=0) is the live state. Click
                // is a no-op — there's nothing to restore to.
                if (!revId || revId === '0') return;
                var v = (timelineVersions || []).filter(function (x) {
                    return String(x.revision_id) === String(revId);
                })[0];
                if (v) openVersionPreview(v);
                return;
            }
            if (t.classList.contains('sw-rail-chip-undo')) {
                undoTool(parseInt(t.getAttribute('data-msg'), 10), t.getAttribute('data-tool-id'));
                return;
            }
            if (t.classList.contains('sw-rail-mic')) {
                toggleVoice();
                return;
            }
            if (t.classList.contains('sw-rail-pick')) {
                togglePickMode();
                return;
            }
            if (t.classList.contains('sw-rail-pick-swatch-custom')) {
                openCustomSwatchPicker();
                return;
            }
            if (t.classList.contains('sw-rail-pick-swatch')) {
                pickSwatchClicked(t.getAttribute('data-hex'));
                return;
            }
            if (t.classList.contains('sw-rail-attach')) {
                var input = root.querySelector('.sw-rail-file-input');
                if (input) input.click();
                return;
            }
            if (t.classList.contains('sw-rail-attachment-remove')) {
                if (t.getAttribute('data-pick-remove')) {
                    consumePickedContext();
                    return;
                }
                removeAttachment(parseInt(t.getAttribute('data-idx'), 10));
                return;
            }
            if (t.classList.contains('sw-rail-preview-recovery-chip')) {
                // Inner X clears the stash; rest of chip re-opens overlay.
                var inner = e.target.closest('[data-action="dismiss"]');
                if (inner) {
                    clearStashedPreview();
                    renderPreviewRecovery();
                    return;
                }
                var stash = getStashedPreview();
                if (stash) showPreviewPanel(stash);
                renderPreviewRecovery(); // hides chip while overlay open
                return;
            }
        });

        // File-input change → ingest attachments
        var fileInput = root.querySelector('.sw-rail-file-input');
        if (fileInput) {
            fileInput.addEventListener('change', function (e) {
                handleFiles(e.target.files);
                fileInput.value = '';
            });
        }

        var form = root.querySelector('.sw-rail-composer');
        if (form) {
            form.addEventListener('submit', function (e) {
                e.preventDefault();
                if (listening && recognition) {
                    try { recognition.stop(); } catch (err) {}
                }
                var ta = form.querySelector('textarea');
                if (!ta) return;
                var v = ta.value;
                ta.value = '';
                voiceBaseText = '';
                send(v);
            });
            var ta = form.querySelector('textarea');
            if (ta) {
                ta.addEventListener('keydown', function (e) {
                    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
                        e.preventDefault();
                        form.dispatchEvent(new Event('submit', { cancelable: true }));
                    }
                });
                // Auto-grow on input so long voice transcriptions or pasted
                // text get visible space without exploding the panel.
                var autogrow = function () {
                    ta.style.height = 'auto';
                    ta.style.height = Math.min(ta.scrollHeight, isMobile() ? Math.floor(window.innerHeight * 0.35) : 200) + 'px';
                };
                ta.addEventListener('input', autogrow);
                // Also grow when speech recognition writes into it.
                if (window.MutationObserver) {
                    new MutationObserver(autogrow).observe(ta, { attributes: true, attributeFilter: ['value'] });
                }
            }
        }
    }

    /**
     * When the assistant just called navigate_to to take the user to a new
     * page, the page reload kills the in-flight turn. The conversation
     * resumes here: if the last message in localStorage is a tool_result
     * for a navigate_to call, kick off another chat turn so Claude can
     * react to the new page context (e.g. continue an edit dialogue the
     * user started on the Dashboard).
     */
    function autoResumeAfterNavigation() {
        if (!messages.length) return;
        var last = messages[messages.length - 1];
        if (!last || last.role !== 'user' || !Array.isArray(last.content)) return;
        var isToolResult = last.content.some(function (b) { return b && b.type === 'tool_result'; });
        if (!isToolResult) return;
        var prev = messages[messages.length - 2];
        if (!prev || prev.role !== 'assistant' || !Array.isArray(prev.content)) return;
        var hasNavigate = prev.content.some(function (b) { return b && b.type === 'tool_use' && b.name === 'navigate_to'; });
        if (!hasNavigate) return;
        // Open the rail so the user sees the conversation pick up. Slight
        // delay so the chat turn lands after the shell has painted.
        setCollapsed(false);
        setTimeout(runChatTurn, 250);
    }

    function init() {
        applyAdminBarOffset();
        renderShell();
        // Restore the chat-id so an in-flight conversation keeps writing
        // to the same cloud row across reloads / tab restarts.
        currentChatId = loadCurrentChatId();
        loadHistory();
        renderMessages();
        attach();
        setCollapsed(isCollapsed());
        window.addEventListener('resize', applyAdminBarOffset);
        // Click outside the history dropdown closes it.
        document.addEventListener('click', function (e) {
            var panel = root.querySelector('.sw-rail-history-panel');
            if (!panel || panel.hidden) return;
            if (panel.contains(e.target)) return;
            if (e.target.closest && e.target.closest('.sw-rail-history')) return;
            hideHistoryPanel();
        }, true);
        // Esc closes the history dropdown.
        document.addEventListener('keydown', function (e) {
            if (e.key !== 'Escape') return;
            var panel = root.querySelector('.sw-rail-history-panel');
            if (panel && !panel.hidden) hideHistoryPanel();
        });
        // Resume any auto-confirm tools that were left mid-flight by a
        // page reload (e.g. user closed the tab during a multi-image
        // generation). Safe no-op when there's nothing to do.
        autoConfirmEligible();
        // Pick up an edit dialogue that was navigated mid-flight via
        // navigate_to (e.g. user asked "redesign my about page" from
        // Dashboard and we redirected to the About editor).
        autoResumeAfterNavigation();
        fetchTimeline();
    }

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